From a8b12e8eb8c6c2de8f40f688129ec2ccd76a30e7 Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Wed, 11 Mar 2026 10:48:37 -0400 Subject: [PATCH 01/92] Remove Common Room sync scripts (#13347) Co-authored-by: openhands --- enterprise/sync/common_room_sync.py | 562 ------------------ enterprise/sync/test_common_room_sync.py | 51 -- .../sync/test_conversation_count_query.py | 83 --- 3 files changed, 696 deletions(-) delete mode 100755 enterprise/sync/common_room_sync.py delete mode 100755 enterprise/sync/test_common_room_sync.py delete mode 100755 enterprise/sync/test_conversation_count_query.py diff --git a/enterprise/sync/common_room_sync.py b/enterprise/sync/common_room_sync.py deleted file mode 100755 index 8d0ede2c6d..0000000000 --- a/enterprise/sync/common_room_sync.py +++ /dev/null @@ -1,562 +0,0 @@ -#!/usr/bin/env python3 -""" -Common Room Sync - -This script queries the database to count conversations created by each user, -then creates or updates a signal in Common Room for each user with their -conversation count. -""" - -import asyncio -import logging -import os -import sys -import time -from datetime import UTC, datetime -from typing import Any, Dict, List, Optional, Set - -import requests -from sqlalchemy import text - -# Add the parent directory to the path so we can import from storage -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from server.auth.token_manager import get_keycloak_admin -from storage.database import get_engine - -# Configure logging -logging.basicConfig( - level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger('common_room_sync') - -# Common Room API configuration -COMMON_ROOM_API_KEY = os.environ.get('COMMON_ROOM_API_KEY') -COMMON_ROOM_DESTINATION_SOURCE_ID = os.environ.get('COMMON_ROOM_DESTINATION_SOURCE_ID') -COMMON_ROOM_API_BASE_URL = 'https://api.commonroom.io/community/v1' - -# Sync configuration -BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '100')) -KEYCLOAK_BATCH_SIZE = int(os.environ.get('KEYCLOAK_BATCH_SIZE', '20')) -MAX_RETRIES = int(os.environ.get('MAX_RETRIES', '3')) -INITIAL_BACKOFF_SECONDS = float(os.environ.get('INITIAL_BACKOFF_SECONDS', '1')) -MAX_BACKOFF_SECONDS = float(os.environ.get('MAX_BACKOFF_SECONDS', '60')) -BACKOFF_FACTOR = float(os.environ.get('BACKOFF_FACTOR', '2')) -RATE_LIMIT = float(os.environ.get('RATE_LIMIT', '2')) # Requests per second - - -class CommonRoomSyncError(Exception): - """Base exception for Common Room sync errors.""" - - -class DatabaseError(CommonRoomSyncError): - """Exception for database errors.""" - - -class CommonRoomAPIError(CommonRoomSyncError): - """Exception for Common Room API errors.""" - - -class KeycloakClientError(CommonRoomSyncError): - """Exception for Keycloak client errors.""" - - -def get_recent_conversations(minutes: int = 60) -> List[Dict[str, Any]]: - """Get conversations created in the past N minutes. - - Args: - minutes: Number of minutes to look back for new conversations. - - Returns: - A list of dictionaries, each containing conversation details. - - Raises: - DatabaseError: If the database query fails. - """ - try: - # Use a different syntax for the interval that works with pg8000 - query = text(""" - SELECT - conversation_id, user_id, title, created_at - FROM - conversation_metadata - WHERE - created_at >= NOW() - (INTERVAL '1 minute' * :minutes) - ORDER BY - created_at DESC - """) - - with get_engine().connect() as connection: - result = connection.execute(query, {'minutes': minutes}) - conversations = [ - { - 'conversation_id': row[0], - 'user_id': row[1], - 'title': row[2], - 'created_at': row[3].isoformat() if row[3] else None, - } - for row in result - ] - - logger.info( - f'Retrieved {len(conversations)} conversations created in the past {minutes} minutes' - ) - return conversations - except Exception as e: - logger.exception(f'Error querying recent conversations: {e}') - raise DatabaseError(f'Failed to query recent conversations: {e}') - - -async def get_users_from_keycloak(user_ids: Set[str]) -> Dict[str, Dict[str, Any]]: - """Get user information from Keycloak for a set of user IDs. - - Args: - user_ids: A set of user IDs to look up. - - Returns: - A dictionary mapping user IDs to user information dictionaries. - - Raises: - KeycloakClientError: If the Keycloak API call fails. - """ - try: - # Get Keycloak admin client - keycloak_admin = get_keycloak_admin() - - # Create a dictionary to store user information - user_info_dict = {} - - # Convert set to list for easier batching - user_id_list = list(user_ids) - - # Process user IDs in batches - for i in range(0, len(user_id_list), KEYCLOAK_BATCH_SIZE): - batch = user_id_list[i : i + KEYCLOAK_BATCH_SIZE] - batch_tasks = [] - - # Create tasks for each user ID in the batch - for user_id in batch: - # Use the Keycloak admin client to get user by ID - batch_tasks.append(get_user_by_id(keycloak_admin, user_id)) - - # Run the batch of tasks concurrently - batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True) - - # Process the results - for user_id, result in zip(batch, batch_results): - if isinstance(result, Exception): - logger.warning(f'Error getting user {user_id}: {result}') - continue - - if result and isinstance(result, dict): - user_info_dict[user_id] = { - 'username': result.get('username'), - 'email': result.get('email'), - 'id': result.get('id'), - } - - logger.info( - f'Retrieved information for {len(user_info_dict)} users from Keycloak' - ) - return user_info_dict - - except Exception as e: - error_msg = f'Error getting users from Keycloak: {e}' - logger.exception(error_msg) - raise KeycloakClientError(error_msg) - - -async def get_user_by_id(keycloak_admin, user_id: str) -> Optional[Dict[str, Any]]: - """Get a user from Keycloak by ID. - - Args: - keycloak_admin: The Keycloak admin client. - user_id: The user ID to look up. - - Returns: - A dictionary with the user's information, or None if not found. - """ - try: - # Use the Keycloak admin client to get user by ID - user = keycloak_admin.get_user(user_id) - if user: - logger.debug( - f"Found user in Keycloak: {user.get('username')}, {user.get('email')}" - ) - return user - else: - logger.warning(f'User {user_id} not found in Keycloak') - return None - except Exception as e: - logger.warning(f'Error getting user {user_id} from Keycloak: {e}') - return None - - -def get_user_info( - user_id: str, user_info_cache: Dict[str, Dict[str, Any]] -) -> Optional[Dict[str, str]]: - """Get the email address and GitHub username for a user from the cache. - - Args: - user_id: The user ID to look up. - user_info_cache: A dictionary mapping user IDs to user information. - - Returns: - A dictionary with the user's email and username, or None if not found. - """ - # Check if the user is in the cache - if user_id in user_info_cache: - user_info = user_info_cache[user_id] - logger.debug( - f"Found user info in cache: {user_info.get('username')}, {user_info.get('email')}" - ) - return user_info - else: - logger.warning(f'User {user_id} not found in user info cache') - return None - - -def register_user_in_common_room( - user_id: str, email: str, github_username: str -) -> Dict[str, Any]: - """Create or update a user in Common Room. - - Args: - user_id: The user ID. - email: The user's email address. - github_username: The user's GitHub username. - - Returns: - The API response from Common Room. - - Raises: - CommonRoomAPIError: If the Common Room API request fails. - """ - if not COMMON_ROOM_API_KEY: - raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set') - - if not COMMON_ROOM_DESTINATION_SOURCE_ID: - raise CommonRoomAPIError( - 'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set' - ) - - try: - headers = { - 'Authorization': f'Bearer {COMMON_ROOM_API_KEY}', - 'Content-Type': 'application/json', - } - - # Create or update user in Common Room - user_data = { - 'id': user_id, - 'email': email, - 'username': github_username, - 'github': {'type': 'handle', 'value': github_username}, - } - - user_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/user' - user_response = requests.post(user_url, headers=headers, json=user_data) - - if user_response.status_code not in (200, 202): - logger.error( - f'Failed to create/update user in Common Room: {user_response.text}' - ) - logger.error(f'Response status code: {user_response.status_code}') - raise CommonRoomAPIError( - f'Failed to create/update user: {user_response.text}' - ) - - logger.info( - f'Registered/updated user {user_id} (GitHub: {github_username}) in Common Room' - ) - return user_response.json() - except requests.RequestException as e: - logger.exception(f'Error communicating with Common Room API: {e}') - raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}') - - -def register_conversation_activity( - user_id: str, - conversation_id: str, - conversation_title: str, - created_at: datetime, - email: str, - github_username: str, -) -> Dict[str, Any]: - """Create an activity in Common Room for a new conversation. - - Args: - user_id: The user ID who created the conversation. - conversation_id: The ID of the conversation. - conversation_title: The title of the conversation. - created_at: The datetime object when the conversation was created. - email: The user's email address. - github_username: The user's GitHub username. - - Returns: - The API response from Common Room. - - Raises: - CommonRoomAPIError: If the Common Room API request fails. - """ - if not COMMON_ROOM_API_KEY: - raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set') - - if not COMMON_ROOM_DESTINATION_SOURCE_ID: - raise CommonRoomAPIError( - 'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set' - ) - - try: - headers = { - 'Authorization': f'Bearer {COMMON_ROOM_API_KEY}', - 'Content-Type': 'application/json', - } - - # Format the datetime object to the expected ISO format - formatted_timestamp = ( - created_at.strftime('%Y-%m-%dT%H:%M:%SZ') - if created_at - else time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) - ) - - # Create activity for the conversation - activity_data = { - 'id': f'conversation_{conversation_id}', # Use conversation ID to ensure uniqueness - 'activityType': 'started_session', - 'user': { - 'id': user_id, - 'email': email, - 'github': {'type': 'handle', 'value': github_username}, - 'username': github_username, - }, - 'activityTitle': { - 'type': 'text', - 'value': conversation_title or 'New Conversation', - }, - 'content': { - 'type': 'text', - 'value': f'Started a new conversation: {conversation_title or "Untitled"}', - }, - 'timestamp': formatted_timestamp, - 'url': f'https://app.all-hands.dev/conversations/{conversation_id}', - } - - # Log the activity data for debugging - logger.info(f'Activity data payload: {activity_data}') - - activity_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/activity' - activity_response = requests.post( - activity_url, headers=headers, json=activity_data - ) - - if activity_response.status_code not in (200, 202): - logger.error( - f'Failed to create activity in Common Room: {activity_response.text}' - ) - logger.error(f'Response status code: {activity_response.status_code}') - raise CommonRoomAPIError( - f'Failed to create activity: {activity_response.text}' - ) - - logger.info( - f'Registered conversation activity for user {user_id}, conversation {conversation_id}' - ) - return activity_response.json() - except requests.RequestException as e: - logger.exception(f'Error communicating with Common Room API: {e}') - raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}') - - -def retry_with_backoff(func, *args, **kwargs): - """Retry a function with exponential backoff. - - Args: - func: The function to retry. - *args: Positional arguments to pass to the function. - **kwargs: Keyword arguments to pass to the function. - - Returns: - The result of the function call. - - Raises: - The last exception raised by the function. - """ - backoff = INITIAL_BACKOFF_SECONDS - last_exception = None - - for attempt in range(MAX_RETRIES): - try: - return func(*args, **kwargs) - except Exception as e: - last_exception = e - logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}') - - if attempt < MAX_RETRIES - 1: - sleep_time = min(backoff, MAX_BACKOFF_SECONDS) - logger.info(f'Retrying in {sleep_time:.2f} seconds...') - time.sleep(sleep_time) - backoff *= BACKOFF_FACTOR - else: - logger.exception(f'All {MAX_RETRIES} attempts failed') - raise last_exception - - -async def retry_with_backoff_async(func, *args, **kwargs): - """Retry an async function with exponential backoff. - - Args: - func: The async function to retry. - *args: Positional arguments to pass to the function. - **kwargs: Keyword arguments to pass to the function. - - Returns: - The result of the function call. - - Raises: - The last exception raised by the function. - """ - backoff = INITIAL_BACKOFF_SECONDS - last_exception = None - - for attempt in range(MAX_RETRIES): - try: - return await func(*args, **kwargs) - except Exception as e: - last_exception = e - logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}') - - if attempt < MAX_RETRIES - 1: - sleep_time = min(backoff, MAX_BACKOFF_SECONDS) - logger.info(f'Retrying in {sleep_time:.2f} seconds...') - await asyncio.sleep(sleep_time) - backoff *= BACKOFF_FACTOR - else: - logger.exception(f'All {MAX_RETRIES} attempts failed') - raise last_exception - - -async def async_sync_recent_conversations_to_common_room(minutes: int = 60): - """Async main function to sync recent conversations to Common Room. - - Args: - minutes: Number of minutes to look back for new conversations. - """ - logger.info( - f'Starting Common Room recent conversations sync (past {minutes} minutes)' - ) - - stats = { - 'total_conversations': 0, - 'registered_users': 0, - 'registered_activities': 0, - 'errors': 0, - 'missing_user_info': 0, - } - - try: - # Get conversations created in the past N minutes - recent_conversations = retry_with_backoff(get_recent_conversations, minutes) - stats['total_conversations'] = len(recent_conversations) - - logger.info(f'Processing {len(recent_conversations)} recent conversations') - - if not recent_conversations: - logger.info('No recent conversations found, exiting') - return - - # Extract all unique user IDs - user_ids = {conv['user_id'] for conv in recent_conversations if conv['user_id']} - - # Get user information for all users in batches - user_info_cache = await retry_with_backoff_async( - get_users_from_keycloak, user_ids - ) - - # Track registered users to avoid duplicate registrations - registered_users = set() - - # Process each conversation - for conversation in recent_conversations: - conversation_id = conversation['conversation_id'] - user_id = conversation['user_id'] - title = conversation['title'] - created_at = conversation[ - 'created_at' - ] # This might be a string or datetime object - - try: - # Get user info from cache - user_info = get_user_info(user_id, user_info_cache) - if not user_info: - logger.warning( - f'Could not find user info for user {user_id}, skipping conversation {conversation_id}' - ) - stats['missing_user_info'] += 1 - continue - - email = user_info['email'] - github_username = user_info['username'] - - if not email: - logger.warning( - f'User {user_id} has no email, skipping conversation {conversation_id}' - ) - stats['errors'] += 1 - continue - - # Register user in Common Room if not already registered in this run - if user_id not in registered_users: - register_user_in_common_room(user_id, email, github_username) - registered_users.add(user_id) - stats['registered_users'] += 1 - - # If created_at is a string, parse it to a datetime object - # If it's already a datetime object, use it as is - # If it's None, use current time - created_at_datetime = ( - created_at - if isinstance(created_at, datetime) - else datetime.fromisoformat(created_at.replace('Z', '+00:00')) - if created_at - else datetime.now(UTC) - ) - - # Register conversation activity with email and github username - register_conversation_activity( - user_id, - conversation_id, - title, - created_at_datetime, - email, - github_username, - ) - stats['registered_activities'] += 1 - - # Sleep to respect rate limit - await asyncio.sleep(1 / RATE_LIMIT) - except Exception as e: - logger.exception( - f'Error processing conversation {conversation_id} for user {user_id}: {e}' - ) - stats['errors'] += 1 - except Exception as e: - logger.exception(f'Sync failed: {e}') - raise - finally: - logger.info(f'Sync completed. Stats: {stats}') - - -def sync_recent_conversations_to_common_room(minutes: int = 60): - """Main function to sync recent conversations to Common Room. - - Args: - minutes: Number of minutes to look back for new conversations. - """ - # Run the async function in the event loop - asyncio.run(async_sync_recent_conversations_to_common_room(minutes)) - - -if __name__ == '__main__': - # Default to looking back 60 minutes for new conversations - minutes = int(os.environ.get('SYNC_MINUTES', '60')) - sync_recent_conversations_to_common_room(minutes) diff --git a/enterprise/sync/test_common_room_sync.py b/enterprise/sync/test_common_room_sync.py deleted file mode 100755 index 3670ddf286..0000000000 --- a/enterprise/sync/test_common_room_sync.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for Common Room conversation count sync. - -This script tests the functionality of the Common Room sync script -without making any API calls to Common Room or database connections. -""" - -import os -import sys -import unittest -from unittest.mock import MagicMock, patch - -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from sync.common_room_sync import ( - retry_with_backoff, -) - - -class TestCommonRoomSync(unittest.TestCase): - """Test cases for Common Room sync functionality.""" - - def test_retry_with_backoff(self): - """Test the retry_with_backoff function.""" - # Mock function that succeeds on the second attempt - mock_func = MagicMock( - side_effect=[Exception('First attempt failed'), 'success'] - ) - - # Set environment variables for testing - with patch.dict( - os.environ, - { - 'MAX_RETRIES': '3', - 'INITIAL_BACKOFF_SECONDS': '0.01', - 'BACKOFF_FACTOR': '2', - 'MAX_BACKOFF_SECONDS': '1', - }, - ): - result = retry_with_backoff(mock_func, 'arg1', 'arg2', kwarg1='kwarg1') - - # Check that the function was called twice - self.assertEqual(mock_func.call_count, 2) - # Check that the function was called with the correct arguments - mock_func.assert_called_with('arg1', 'arg2', kwarg1='kwarg1') - # Check that the function returned the expected result - self.assertEqual(result, 'success') - - -if __name__ == '__main__': - unittest.main() diff --git a/enterprise/sync/test_conversation_count_query.py b/enterprise/sync/test_conversation_count_query.py deleted file mode 100755 index e3200eed95..0000000000 --- a/enterprise/sync/test_conversation_count_query.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -"""Test script to verify the conversation count query. - -This script tests the database query to count conversations by user, -without making any API calls to Common Room. -""" - -import os -import sys - -from sqlalchemy import text - -# Add the parent directory to the path so we can import from storage -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from storage.database import get_engine - - -def test_conversation_count_query(): - """Test the query to count conversations by user.""" - try: - # Query to count conversations by user - count_query = text(""" - SELECT - user_id, COUNT(*) as conversation_count - FROM - conversation_metadata - GROUP BY - user_id - """) - - engine = get_engine() - - with engine.connect() as connection: - count_result = connection.execute(count_query) - user_counts = [ - {'user_id': row[0], 'conversation_count': row[1]} - for row in count_result - ] - - print(f'Found {len(user_counts)} users with conversations') - - # Print the first 5 results - for i, user_data in enumerate(user_counts[:5]): - print( - f"User {i+1}: {user_data['user_id']} - {user_data['conversation_count']} conversations" - ) - - # Test the user_entity query for the first user (if any) - if user_counts: - first_user_id = user_counts[0]['user_id'] - - user_query = text(""" - SELECT username, email, id - FROM user_entity - WHERE id = :user_id - """) - - with engine.connect() as connection: - user_result = connection.execute(user_query, {'user_id': first_user_id}) - user_row = user_result.fetchone() - - if user_row: - print(f'\nUser details for {first_user_id}:') - print(f' GitHub Username: {user_row[0]}') - print(f' Email: {user_row[1]}') - print(f' ID: {user_row[2]}') - else: - print( - f'\nNo user details found for {first_user_id} in user_entity table' - ) - - print('\nTest completed successfully') - except Exception as e: - print(f'Error: {str(e)}') - import traceback - - traceback.print_exc() - sys.exit(1) - - -if __name__ == '__main__': - test_conversation_count_query() From 7e663047464eccc697bb632a39debeaeedc3ae76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:09:09 -0500 Subject: [PATCH 02/92] chore(deps): bump pypdf from 6.7.5 to 6.8.0 (#13348) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: openhands --- enterprise/poetry.lock | 6 +++--- poetry.lock | 6 +++--- uv.lock | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index e9bbff231a..1abef0c9b2 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -11599,14 +11599,14 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pypdf" -version = "6.7.5" +version = "6.8.0" description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13"}, - {file = "pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d"}, + {file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"}, + {file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"}, ] [package.extras] diff --git a/poetry.lock b/poetry.lock index 8329cb554a..6865bacdbf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11577,14 +11577,14 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pypdf" -version = "6.7.5" +version = "6.8.0" description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13"}, - {file = "pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d"}, + {file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"}, + {file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"}, ] [package.extras] diff --git a/uv.lock b/uv.lock index 119f273ed9..d4472a0482 100644 --- a/uv.lock +++ b/uv.lock @@ -7383,11 +7383,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.7.5" +version = "6.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/52/37cc0aa9e9d1bf7729a737a0d83f8b3f851c8eb137373d9f71eafb0a3405/pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d", size = 5304278, upload-time = "2026-03-02T09:05:21.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/89/336673efd0a88956562658aba4f0bbef7cb92a6fbcbcaf94926dbc82b408/pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13", size = 331421, upload-time = "2026-03-02T09:05:19.722Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" }, ] [[package]] From 4eb6e4da09a75ca831adf183579cd242bc133832 Mon Sep 17 00:00:00 2001 From: mamoodi Date: Wed, 11 Mar 2026 14:50:13 -0400 Subject: [PATCH 03/92] Release 1.5.0 (#13336) --- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92813ce1a8..cd20c8aa82 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "openhands-frontend", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhands-frontend", - "version": "1.4.0", + "version": "1.5.0", "dependencies": { "@heroui/react": "2.8.7", "@microlink/react-json-view": "^1.27.1", diff --git a/frontend/package.json b/frontend/package.json index 8448faba21..ef71313459 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "openhands-frontend", - "version": "1.4.0", + "version": "1.5.0", "private": true, "type": "module", "engines": { diff --git a/pyproject.toml b/pyproject.toml index c54b3129e2..1040e1bd7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,7 +144,7 @@ runtime = [ [tool.poetry] name = "openhands-ai" -version = "1.4.0" +version = "1.5.0" description = "OpenHands: Code Less, Make More" authors = [ "OpenHands" ] license = "MIT" From ab78d7d6e8fc14a88d82eb8b3474a90012c587be Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Thu, 12 Mar 2026 09:11:35 -0600 Subject: [PATCH 04/92] fix: Set correct user context in webhook callbacks based on sandbox owner (#13340) Co-authored-by: openhands --- .../event_callback/webhook_router.py | 68 +++-- .../app_server/test_webhook_router_auth.py | 259 +++++++++++++++--- ...test_webhook_router_parent_conversation.py | 139 ++++------ .../app_server/test_webhook_router_stats.py | 87 ++---- 4 files changed, 344 insertions(+), 209 deletions(-) diff --git a/openhands/app_server/event_callback/webhook_router.py b/openhands/app_server/event_callback/webhook_router.py index 64863bae40..70f1a2a203 100644 --- a/openhands/app_server/event_callback/webhook_router.py +++ b/openhands/app_server/event_callback/webhook_router.py @@ -6,7 +6,7 @@ import logging import pkgutil from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi.security import APIKeyHeader from jwt import InvalidTokenError from pydantic import SecretStr @@ -23,61 +23,87 @@ from openhands.app_server.config import ( depends_app_conversation_info_service, depends_event_service, depends_jwt_service, - depends_sandbox_service, get_event_callback_service, + get_global_config, + get_sandbox_service, ) from openhands.app_server.errors import AuthError from openhands.app_server.event.event_service import EventService from openhands.app_server.sandbox.sandbox_models import SandboxInfo -from openhands.app_server.sandbox.sandbox_service import SandboxService from openhands.app_server.services.injector import InjectorState from openhands.app_server.services.jwt_service import JwtService from openhands.app_server.user.auth_user_context import AuthUserContext from openhands.app_server.user.specifiy_user_context import ( + ADMIN, USER_CONTEXT_ATTR, SpecifyUserContext, - as_admin, ) -from openhands.app_server.user.user_context import UserContext from openhands.integrations.provider import ProviderType from openhands.sdk import ConversationExecutionStatus, Event from openhands.sdk.event import ConversationStateUpdateEvent +from openhands.server.types import AppMode from openhands.server.user_auth.default_user_auth import DefaultUserAuth from openhands.server.user_auth.user_auth import ( get_for_user as get_user_auth_for_user, ) router = APIRouter(prefix='/webhooks', tags=['Webhooks']) -sandbox_service_dependency = depends_sandbox_service() event_service_dependency = depends_event_service() app_conversation_info_service_dependency = depends_app_conversation_info_service() jwt_dependency = depends_jwt_service() +app_mode = get_global_config().app_mode _logger = logging.getLogger(__name__) async def valid_sandbox( - user_context: UserContext = Depends(as_admin), + request: Request, session_api_key: str = Depends( APIKeyHeader(name='X-Session-API-Key', auto_error=False) ), - sandbox_service: SandboxService = sandbox_service_dependency, ) -> SandboxInfo: + """Use a session api key for validation, and get a sandbox. Subsequent actions + are executed in the context of the owner of the sandbox""" if not session_api_key: raise HTTPException( status.HTTP_401_UNAUTHORIZED, detail='X-Session-API-Key header is required' ) - sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(session_api_key) - if sandbox_info is None: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key' + # Create a state which will be used internally only for this operation + state = InjectorState() + + # Since we need access to all sandboxes, this is executed in the context of the admin. + setattr(state, USER_CONTEXT_ATTR, ADMIN) + async with get_sandbox_service(state) as sandbox_service: + sandbox_info = await sandbox_service.get_sandbox_by_session_api_key( + session_api_key ) - return sandbox_info + if sandbox_info is None: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key' + ) + + # In SAAS Mode there is always a user, so we set the owner of the sandbox + # as the current user (Validated by the session_api_key they provided) + if sandbox_info.created_by_user_id: + setattr( + request.state, + USER_CONTEXT_ATTR, + SpecifyUserContext(sandbox_info.created_by_user_id), + ) + elif app_mode == AppMode.SAAS: + _logger.error( + 'Sandbox had no user specified', extra={'sandbox_id': sandbox_info.id} + ) + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, detail='Sandbox had no user specified' + ) + + return sandbox_info async def valid_conversation( conversation_id: UUID, - sandbox_info: SandboxInfo, + sandbox_info: SandboxInfo = Depends(valid_sandbox), app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency, ) -> AppConversationInfo: app_conversation_info = ( @@ -90,9 +116,11 @@ async def valid_conversation( sandbox_id=sandbox_info.id, created_by_user_id=sandbox_info.created_by_user_id, ) + + # Sanity check - Make sure that the conversation and sandbox were created by the same user if app_conversation_info.created_by_user_id != sandbox_info.created_by_user_id: - # Make sure that the conversation and sandbox were created by the same user raise AuthError() + return app_conversation_info @@ -100,12 +128,10 @@ async def valid_conversation( async def on_conversation_update( conversation_info: ConversationInfo, sandbox_info: SandboxInfo = Depends(valid_sandbox), + existing: AppConversationInfo = Depends(valid_conversation), app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency, ) -> Success: """Webhook callback for when a conversation starts, pauses, resumes, or deletes.""" - existing = await valid_conversation( - conversation_info.id, sandbox_info, app_conversation_info_service - ) # If the conversation is being deleted, no action is required... # Later we may consider deleting the conversation if it exists... @@ -139,15 +165,11 @@ async def on_conversation_update( async def on_event( events: list[Event], conversation_id: UUID, - sandbox_info: SandboxInfo = Depends(valid_sandbox), + app_conversation_info: AppConversationInfo = Depends(valid_conversation), app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency, event_service: EventService = event_service_dependency, ) -> Success: """Webhook callback for when event stream events occur.""" - app_conversation_info = await valid_conversation( - conversation_id, sandbox_info, app_conversation_info_service - ) - try: # Save events... await asyncio.gather( diff --git a/tests/unit/app_server/test_webhook_router_auth.py b/tests/unit/app_server/test_webhook_router_auth.py index 6f2837a4b3..b2ba0a0626 100644 --- a/tests/unit/app_server/test_webhook_router_auth.py +++ b/tests/unit/app_server/test_webhook_router_auth.py @@ -3,7 +3,8 @@ This module tests the webhook authentication and authorization logic. """ -from unittest.mock import AsyncMock, MagicMock +import contextlib +from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest @@ -14,7 +15,49 @@ from openhands.app_server.event_callback.webhook_router import ( valid_sandbox, ) from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus -from openhands.app_server.user.specifiy_user_context import ADMIN +from openhands.app_server.user.specifiy_user_context import ( + USER_CONTEXT_ATTR, + SpecifyUserContext, +) +from openhands.server.types import AppMode + + +class MockRequestState: + """A mock request state that tracks attribute assignments.""" + + def __init__(self): + self._state = {} + self._attributes = {} + + def __setattr__(self, name, value): + if name.startswith('_'): + super().__setattr__(name, value) + else: + self._attributes[name] = value + + def __getattr__(self, name): + if name in self._attributes: + return self._attributes[name] + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + +def create_mock_request(): + """Create a mock FastAPI Request object with proper state.""" + request = MagicMock() + request.state = MockRequestState() + return request + + +def create_sandbox_service_context_manager(sandbox_service): + """Create an async context manager that yields the given sandbox service.""" + + @contextlib.asynccontextmanager + async def _context_manager(state, request=None): + yield sandbox_service + + return _context_manager class TestValidSandbox: @@ -22,14 +65,15 @@ class TestValidSandbox: @pytest.mark.asyncio async def test_valid_sandbox_with_valid_api_key(self): - """Test that valid API key returns sandbox info.""" + """Test that valid API key returns sandbox info and sets user_context.""" # Arrange session_api_key = 'valid-api-key-123' + user_id = 'user-123' expected_sandbox = SandboxInfo( id='sandbox-123', status=SandboxStatus.RUNNING, session_api_key=session_api_key, - created_by_user_id='user-123', + created_by_user_id=user_id, sandbox_spec_id='spec-123', ) @@ -38,12 +82,17 @@ class TestValidSandbox: return_value=expected_sandbox ) + mock_request = create_mock_request() + # Act - result = await valid_sandbox( - user_context=ADMIN, - session_api_key=session_api_key, - sandbox_service=mock_sandbox_service, - ) + with patch( + 'openhands.app_server.event_callback.webhook_router.get_sandbox_service', + create_sandbox_service_context_manager(mock_sandbox_service), + ): + result = await valid_sandbox( + request=mock_request, + session_api_key=session_api_key, + ) # Assert assert result == expected_sandbox @@ -51,18 +100,136 @@ class TestValidSandbox: session_api_key ) + # Verify user_context is set correctly on request.state + assert USER_CONTEXT_ATTR in mock_request.state._attributes + user_context = mock_request.state._attributes[USER_CONTEXT_ATTR] + assert isinstance(user_context, SpecifyUserContext) + assert user_context.user_id == user_id + + @pytest.mark.asyncio + async def test_valid_sandbox_sets_user_context_to_sandbox_owner(self): + """Test that user_context is set to the sandbox owner's user ID.""" + # Arrange + session_api_key = 'valid-api-key' + sandbox_owner_id = 'sandbox-owner-user-id' + expected_sandbox = SandboxInfo( + id='sandbox-456', + status=SandboxStatus.RUNNING, + session_api_key=session_api_key, + created_by_user_id=sandbox_owner_id, + sandbox_spec_id='spec-456', + ) + + mock_sandbox_service = AsyncMock() + mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock( + return_value=expected_sandbox + ) + + mock_request = create_mock_request() + + # Act + with patch( + 'openhands.app_server.event_callback.webhook_router.get_sandbox_service', + create_sandbox_service_context_manager(mock_sandbox_service), + ): + await valid_sandbox( + request=mock_request, + session_api_key=session_api_key, + ) + + # Assert - user_context should be set to the sandbox owner + assert USER_CONTEXT_ATTR in mock_request.state._attributes + user_context = mock_request.state._attributes[USER_CONTEXT_ATTR] + assert isinstance(user_context, SpecifyUserContext) + assert user_context.user_id == sandbox_owner_id + + @pytest.mark.asyncio + async def test_valid_sandbox_no_user_context_when_no_user_id(self): + """Test that user_context is not set when sandbox has no created_by_user_id.""" + # Arrange + session_api_key = 'valid-api-key' + expected_sandbox = SandboxInfo( + id='sandbox-789', + status=SandboxStatus.RUNNING, + session_api_key=session_api_key, + created_by_user_id=None, # No user ID + sandbox_spec_id='spec-789', + ) + + mock_sandbox_service = AsyncMock() + mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock( + return_value=expected_sandbox + ) + + mock_request = create_mock_request() + + # Act + with patch( + 'openhands.app_server.event_callback.webhook_router.get_sandbox_service', + create_sandbox_service_context_manager(mock_sandbox_service), + ): + result = await valid_sandbox( + request=mock_request, + session_api_key=session_api_key, + ) + + # Assert - sandbox is returned but user_context should NOT be set + assert result == expected_sandbox + + # Verify user_context is NOT set on request.state + assert USER_CONTEXT_ATTR not in mock_request.state._attributes + + @pytest.mark.asyncio + async def test_valid_sandbox_no_user_context_when_no_user_id_raises_401_in_saas_mode( + self, + ): + """Test that user_context is not set when sandbox has no created_by_user_id.""" + # Arrange + session_api_key = 'valid-api-key' + expected_sandbox = SandboxInfo( + id='sandbox-789', + status=SandboxStatus.RUNNING, + session_api_key=session_api_key, + created_by_user_id=None, # No user ID + sandbox_spec_id='spec-789', + ) + + mock_sandbox_service = AsyncMock() + mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock( + return_value=expected_sandbox + ) + + mock_request = create_mock_request() + + # Act + with ( + patch( + 'openhands.app_server.event_callback.webhook_router.get_sandbox_service', + create_sandbox_service_context_manager(mock_sandbox_service), + ), + patch( + 'openhands.app_server.event_callback.webhook_router.app_mode', + AppMode.SAAS, + ), + ): + with pytest.raises(HTTPException) as excinfo: + await valid_sandbox( + request=mock_request, + session_api_key=session_api_key, + ) + assert excinfo.value.status_code == 401 + @pytest.mark.asyncio async def test_valid_sandbox_without_api_key_raises_401(self): """Test that missing API key raises 401 error.""" # Arrange - mock_sandbox_service = AsyncMock() + mock_request = create_mock_request() # Act & Assert with pytest.raises(HTTPException) as exc_info: await valid_sandbox( - user_context=ADMIN, + request=mock_request, session_api_key=None, - sandbox_service=mock_sandbox_service, ) assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED @@ -78,13 +245,18 @@ class TestValidSandbox: return_value=None ) + mock_request = create_mock_request() + # Act & Assert - with pytest.raises(HTTPException) as exc_info: - await valid_sandbox( - user_context=ADMIN, - session_api_key=session_api_key, - sandbox_service=mock_sandbox_service, - ) + with patch( + 'openhands.app_server.event_callback.webhook_router.get_sandbox_service', + create_sandbox_service_context_manager(mock_sandbox_service), + ): + with pytest.raises(HTTPException) as exc_info: + await valid_sandbox( + request=mock_request, + session_api_key=session_api_key, + ) assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED assert 'Invalid session API key' in exc_info.value.detail @@ -95,13 +267,13 @@ class TestValidSandbox: # Arrange - empty string is falsy, so it gets rejected at the check session_api_key = '' mock_sandbox_service = AsyncMock() + mock_request = create_mock_request() # Act & Assert - should raise 401 because empty string fails the truth check with pytest.raises(HTTPException) as exc_info: await valid_sandbox( - user_context=ADMIN, + request=mock_request, session_api_key=session_api_key, - sandbox_service=mock_sandbox_service, ) assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED @@ -263,12 +435,17 @@ class TestWebhookAuthenticationIntegration: return_value=conversation_info ) + mock_request = create_mock_request() + # Act - Call valid_sandbox first - sandbox_result = await valid_sandbox( - user_context=ADMIN, - session_api_key=session_api_key, - sandbox_service=mock_sandbox_service, - ) + with patch( + 'openhands.app_server.event_callback.webhook_router.get_sandbox_service', + create_sandbox_service_context_manager(mock_sandbox_service), + ): + sandbox_result = await valid_sandbox( + request=mock_request, + session_api_key=session_api_key, + ) # Then call valid_conversation conversation_result = await valid_conversation( @@ -291,13 +468,18 @@ class TestWebhookAuthenticationIntegration: return_value=None ) + mock_request = create_mock_request() + # Act & Assert - Should fail at valid_sandbox - with pytest.raises(HTTPException) as exc_info: - await valid_sandbox( - user_context=ADMIN, - session_api_key=session_api_key, - sandbox_service=mock_sandbox_service, - ) + with patch( + 'openhands.app_server.event_callback.webhook_router.get_sandbox_service', + create_sandbox_service_context_manager(mock_sandbox_service), + ): + with pytest.raises(HTTPException) as exc_info: + await valid_sandbox( + request=mock_request, + session_api_key=session_api_key, + ) assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED @@ -328,12 +510,17 @@ class TestWebhookAuthenticationIntegration: return_value=different_user_info ) + mock_request = create_mock_request() + # Act - valid_sandbox succeeds - sandbox_result = await valid_sandbox( - user_context=ADMIN, - session_api_key=session_api_key, - sandbox_service=mock_sandbox_service, - ) + with patch( + 'openhands.app_server.event_callback.webhook_router.get_sandbox_service', + create_sandbox_service_context_manager(mock_sandbox_service), + ): + sandbox_result = await valid_sandbox( + request=mock_request, + session_api_key=session_api_key, + ) # But valid_conversation fails from openhands.app_server.errors import AuthError diff --git a/tests/unit/app_server/test_webhook_router_parent_conversation.py b/tests/unit/app_server/test_webhook_router_parent_conversation.py index 4d6252ff5d..23ae6a0612 100644 --- a/tests/unit/app_server/test_webhook_router_parent_conversation.py +++ b/tests/unit/app_server/test_webhook_router_parent_conversation.py @@ -5,7 +5,7 @@ conversations are updated via the on_conversation_update webhook endpoint. """ from typing import AsyncGenerator -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from uuid import uuid4 import pytest @@ -137,17 +137,13 @@ class TestOnConversationUpdateParentConversationId: parent_conversation_id=parent_id, ) - # Mock valid_conversation to return existing conversation - with patch( - 'openhands.app_server.event_callback.webhook_router.valid_conversation', - return_value=existing_conv, - ): - # Act - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with dependencies + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + existing=existing_conv, + app_conversation_info_service=app_conversation_info_service, + ) # Assert assert isinstance(result, Success) @@ -191,17 +187,13 @@ class TestOnConversationUpdateParentConversationId: parent_conversation_id=None, ) - # Mock valid_conversation to return existing conversation - with patch( - 'openhands.app_server.event_callback.webhook_router.valid_conversation', - return_value=existing_conv, - ): - # Act - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with dependencies + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + existing=existing_conv, + app_conversation_info_service=app_conversation_info_service, + ) # Assert assert isinstance(result, Success) @@ -242,17 +234,13 @@ class TestOnConversationUpdateParentConversationId: created_by_user_id=sandbox_info.created_by_user_id, ) - # Mock valid_conversation to return stub (as it would for new conversation) - with patch( - 'openhands.app_server.event_callback.webhook_router.valid_conversation', - return_value=stub_conv, - ): - # Act - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with dependencies + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + existing=stub_conv, + app_conversation_info_service=app_conversation_info_service, + ) # Assert assert isinstance(result, Success) @@ -302,17 +290,13 @@ class TestOnConversationUpdateParentConversationId: parent_conversation_id=parent_id, ) - # Mock valid_conversation to return existing conversation - with patch( - 'openhands.app_server.event_callback.webhook_router.valid_conversation', - return_value=existing_conv, - ): - # Act - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with dependencies + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + existing=existing_conv, + app_conversation_info_service=app_conversation_info_service, + ) # Assert assert isinstance(result, Success) @@ -366,9 +350,8 @@ class TestOnConversationUpdateParentConversationId: parent_conversation_id=parent_id, ) - # Mock valid_conversation to return conversation with parent - # In real scenario, this would be retrieved from DB after first save - async def mock_valid_conv(*args, **kwargs): + # Act - Update multiple times, simulating what valid_conversation would return + for _ in range(3): # After first save, get from DB with parent preserved saved = await app_conversation_info_service.get_app_conversation_info( conversation_id @@ -376,21 +359,17 @@ class TestOnConversationUpdateParentConversationId: if saved: # Override created_by_user_id for auth check saved.created_by_user_id = 'user_123' - return saved - return initial_conv + existing = saved + else: + existing = initial_conv - with patch( - 'openhands.app_server.event_callback.webhook_router.valid_conversation', - side_effect=mock_valid_conv, - ): - # Act - Update multiple times - for _ in range(3): - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - app_conversation_info_service=app_conversation_info_service, - ) - assert isinstance(result, Success) + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + existing=existing, + app_conversation_info_service=app_conversation_info_service, + ) + assert isinstance(result, Success) # Assert saved_conv = await app_conversation_info_service.get_app_conversation_info( @@ -441,17 +420,13 @@ class TestOnConversationUpdateParentConversationId: # Set conversation to DELETING status mock_conversation_info.execution_status = ConversationExecutionStatus.DELETING - # Mock valid_conversation (though it won't be called for DELETING status) - with patch( - 'openhands.app_server.event_callback.webhook_router.valid_conversation', - return_value=existing_conv, - ): - # Act - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with dependencies + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + existing=existing_conv, + app_conversation_info_service=app_conversation_info_service, + ) # Assert - Function returns success but doesn't update assert isinstance(result, Success) @@ -498,17 +473,13 @@ class TestOnConversationUpdateParentConversationId: parent_conversation_id=parent_id, ) - # Mock valid_conversation to return existing conversation - with patch( - 'openhands.app_server.event_callback.webhook_router.valid_conversation', - return_value=existing_conv, - ): - # Act - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with dependencies + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + existing=existing_conv, + app_conversation_info_service=app_conversation_info_service, + ) # Assert assert isinstance(result, Success) diff --git a/tests/unit/app_server/test_webhook_router_stats.py b/tests/unit/app_server/test_webhook_router_stats.py index 70d51dee31..229eeb8e17 100644 --- a/tests/unit/app_server/test_webhook_router_stats.py +++ b/tests/unit/app_server/test_webhook_router_stats.py @@ -451,11 +451,9 @@ class TestOnEventStatsProcessing: @pytest.mark.asyncio async def test_on_event_processes_stats_events(self): """Test that on_event processes stats events.""" + from unittest.mock import patch + from openhands.app_server.event_callback.webhook_router import on_event - from openhands.app_server.sandbox.sandbox_models import ( - SandboxInfo, - SandboxStatus, - ) conversation_id = uuid4() sandbox_id = 'sandbox_123' @@ -482,15 +480,6 @@ class TestOnEventStatsProcessing: events = [stats_event, other_event] - # Mock dependencies - mock_sandbox = SandboxInfo( - id=sandbox_id, - status=SandboxStatus.RUNNING, - session_api_key='test_key', - created_by_user_id='user_123', - sandbox_spec_id='spec_123', - ) - mock_app_conversation_info = AppConversationInfo( id=conversation_id, sandbox_id=sandbox_id, @@ -499,9 +488,6 @@ class TestOnEventStatsProcessing: mock_event_service = AsyncMock() mock_app_conversation_info_service = AsyncMock() - mock_app_conversation_info_service.get_app_conversation_info.return_value = ( - mock_app_conversation_info - ) # Set up process_stats_event to call update_conversation_statistics async def process_stats_event_side_effect(event, conversation_id): @@ -519,44 +505,33 @@ class TestOnEventStatsProcessing: process_stats_event_side_effect ) - with ( - patch( - 'openhands.app_server.event_callback.webhook_router.valid_sandbox', - return_value=mock_sandbox, - ), - patch( - 'openhands.app_server.event_callback.webhook_router.valid_conversation', - return_value=mock_app_conversation_info, - ), - patch( - 'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close' - ) as mock_callbacks, - ): + with patch( + 'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close' + ) as mock_callbacks: + # Call on_event directly with dependencies await on_event( events=events, conversation_id=conversation_id, - sandbox_info=mock_sandbox, + app_conversation_info=mock_app_conversation_info, app_conversation_info_service=mock_app_conversation_info_service, event_service=mock_event_service, ) - # Verify events were saved - assert mock_event_service.save_event.call_count == 2 + # Verify events were saved + assert mock_event_service.save_event.call_count == 2 - # Verify stats event was processed - mock_app_conversation_info_service.update_conversation_statistics.assert_called_once() + # Verify stats event was processed + mock_app_conversation_info_service.update_conversation_statistics.assert_called_once() - # Verify callbacks were scheduled - mock_callbacks.assert_called_once() + # Verify callbacks were scheduled + mock_callbacks.assert_called_once() @pytest.mark.asyncio async def test_on_event_skips_non_stats_events(self): """Test that on_event skips non-stats events.""" + from unittest.mock import patch + from openhands.app_server.event_callback.webhook_router import on_event - from openhands.app_server.sandbox.sandbox_models import ( - SandboxInfo, - SandboxStatus, - ) from openhands.events.action.message import MessageAction conversation_id = uuid4() @@ -568,14 +543,6 @@ class TestOnEventStatsProcessing: MessageAction(content='test'), ] - mock_sandbox = SandboxInfo( - id=sandbox_id, - status=SandboxStatus.RUNNING, - session_api_key='test_key', - created_by_user_id='user_123', - sandbox_spec_id='spec_123', - ) - mock_app_conversation_info = AppConversationInfo( id=conversation_id, sandbox_id=sandbox_id, @@ -584,30 +551,18 @@ class TestOnEventStatsProcessing: mock_event_service = AsyncMock() mock_app_conversation_info_service = AsyncMock() - mock_app_conversation_info_service.get_app_conversation_info.return_value = ( - mock_app_conversation_info - ) - with ( - patch( - 'openhands.app_server.event_callback.webhook_router.valid_sandbox', - return_value=mock_sandbox, - ), - patch( - 'openhands.app_server.event_callback.webhook_router.valid_conversation', - return_value=mock_app_conversation_info, - ), - patch( - 'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close' - ), + with patch( + 'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close' ): + # Call on_event directly with dependencies await on_event( events=events, conversation_id=conversation_id, - sandbox_info=mock_sandbox, + app_conversation_info=mock_app_conversation_info, app_conversation_info_service=mock_app_conversation_info_service, event_service=mock_event_service, ) - # Verify stats update was NOT called - mock_app_conversation_info_service.update_conversation_statistics.assert_not_called() + # Verify stats update was NOT called + mock_app_conversation_info_service.update_conversation_statistics.assert_not_called() From a176a135da26eab5207076d30f072f0926a6c5a3 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:23:08 +0700 Subject: [PATCH 05/92] fix: sdk conversations not appearing in cloud ui (#13296) --- .../saas_app_conversation_info_injector.py | 3 + ..._saas_sql_app_conversation_info_service.py | 128 ++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/enterprise/server/utils/saas_app_conversation_info_injector.py b/enterprise/server/utils/saas_app_conversation_info_injector.py index 64e33797fd..16a7952a33 100644 --- a/enterprise/server/utils/saas_app_conversation_info_injector.py +++ b/enterprise/server/utils/saas_app_conversation_info_injector.py @@ -334,7 +334,10 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService): await super().save_app_conversation_info(info) # Get current user_id for SAAS metadata + # Fall back to info.created_by_user_id for webhook callbacks (which use ADMIN context) user_id_str = await self.user_context.get_user_id() + if not user_id_str and info.created_by_user_id: + user_id_str = info.created_by_user_id if user_id_str: # Convert string user_id to UUID user_id_uuid = UUID(user_id_str) diff --git a/enterprise/tests/unit/storage/test_saas_sql_app_conversation_info_service.py b/enterprise/tests/unit/storage/test_saas_sql_app_conversation_info_service.py index 84a29be701..2ce5e3599c 100644 --- a/enterprise/tests/unit/storage/test_saas_sql_app_conversation_info_service.py +++ b/enterprise/tests/unit/storage/test_saas_sql_app_conversation_info_service.py @@ -663,3 +663,131 @@ class TestSaasSQLAppConversationInfoServiceAdminContext: admin_page = await admin_service.search_app_conversation_info() assert len(admin_page.items) == 5 + + +class TestSaasSQLAppConversationInfoServiceWebhookFallback: + """Test suite for webhook callback fallback using info.created_by_user_id.""" + + @pytest.mark.asyncio + async def test_save_with_admin_context_uses_created_by_user_id_fallback( + self, + async_session_with_users: AsyncSession, + ): + """Test that save_app_conversation_info uses info.created_by_user_id when user_context returns None. + + This is the key fix for SDK-created conversations: when the webhook endpoint + uses ADMIN context (user_id=None), the service should fall back to using + the created_by_user_id from the AppConversationInfo object. + """ + from storage.stored_conversation_metadata_saas import ( + StoredConversationMetadataSaas, + ) + + from openhands.app_server.user.specifiy_user_context import ADMIN + + # Arrange: Create service with ADMIN context (user_id=None) + admin_service = SaasSQLAppConversationInfoService( + db_session=async_session_with_users, + user_context=ADMIN, + ) + + # Create conversation info with created_by_user_id set (as would come from sandbox_info) + conv_id = uuid4() + conv_info = AppConversationInfo( + id=conv_id, + created_by_user_id=str(USER1_ID), # This should be used as fallback + sandbox_id='sandbox_webhook_test', + title='Webhook Created Conversation', + ) + + # Act: Save using ADMIN context + await admin_service.save_app_conversation_info(conv_info) + + # Assert: SAAS metadata should be created with user_id from info.created_by_user_id + saas_query = select(StoredConversationMetadataSaas).where( + StoredConversationMetadataSaas.conversation_id == str(conv_id) + ) + result = await async_session_with_users.execute(saas_query) + saas_metadata = result.scalar_one_or_none() + + assert saas_metadata is not None, 'SAAS metadata should be created' + assert ( + saas_metadata.user_id == USER1_ID + ), 'user_id should match info.created_by_user_id' + assert saas_metadata.org_id == ORG1_ID, 'org_id should match user current org' + + @pytest.mark.asyncio + async def test_save_with_admin_context_no_user_id_skips_saas_metadata( + self, + async_session_with_users: AsyncSession, + ): + """Test that save_app_conversation_info skips SAAS metadata when both user_context and info have no user_id.""" + from storage.stored_conversation_metadata_saas import ( + StoredConversationMetadataSaas, + ) + + from openhands.app_server.user.specifiy_user_context import ADMIN + + # Arrange: Create service with ADMIN context (user_id=None) + admin_service = SaasSQLAppConversationInfoService( + db_session=async_session_with_users, + user_context=ADMIN, + ) + + # Create conversation info without created_by_user_id + conv_id = uuid4() + conv_info = AppConversationInfo( + id=conv_id, + created_by_user_id=None, # No user_id available + sandbox_id='sandbox_no_user', + title='No User Conversation', + ) + + # Act: Save using ADMIN context with no user_id fallback + await admin_service.save_app_conversation_info(conv_info) + + # Assert: SAAS metadata should NOT be created + saas_query = select(StoredConversationMetadataSaas).where( + StoredConversationMetadataSaas.conversation_id == str(conv_id) + ) + result = await async_session_with_users.execute(saas_query) + saas_metadata = result.scalar_one_or_none() + + assert ( + saas_metadata is None + ), 'SAAS metadata should not be created without user_id' + + @pytest.mark.asyncio + async def test_webhook_created_conversation_visible_to_user( + self, + async_session_with_users: AsyncSession, + ): + """Test end-to-end: conversation saved via webhook is visible to the owning user.""" + from openhands.app_server.user.specifiy_user_context import ADMIN + + # Arrange: Save conversation using ADMIN context (simulating webhook) + admin_service = SaasSQLAppConversationInfoService( + db_session=async_session_with_users, + user_context=ADMIN, + ) + + conv_id = uuid4() + conv_info = AppConversationInfo( + id=conv_id, + created_by_user_id=str(USER1_ID), + sandbox_id='sandbox_webhook_e2e', + title='E2E Webhook Conversation', + ) + await admin_service.save_app_conversation_info(conv_info) + + # Act: Query as the owning user + user1_service = SaasSQLAppConversationInfoService( + db_session=async_session_with_users, + user_context=SpecifyUserContext(user_id=str(USER1_ID)), + ) + user1_page = await user1_service.search_app_conversation_info() + + # Assert: User should see the webhook-created conversation + assert len(user1_page.items) == 1 + assert user1_page.items[0].id == conv_id + assert user1_page.items[0].title == 'E2E Webhook Conversation' From 127e6117067aeb68ba7443c298f10e1c1233b7b7 Mon Sep 17 00:00:00 2001 From: aivong-openhands Date: Thu, 12 Mar 2026 13:22:39 -0500 Subject: [PATCH 06/92] Fix GHSA-78cv-mqj4-43f7: Update tornado to 6.5.5 (#13362) Co-authored-by: OpenHands CVE Fix Bot --- enterprise/poetry.lock | 24 +++++++++++------------- poetry.lock | 24 +++++++++++------------- uv.lock | 24 +++++++++++------------- 3 files changed, 33 insertions(+), 39 deletions(-) diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index 1abef0c9b2..ff6f6f3a78 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -13771,24 +13771,22 @@ files = [ [[package]] name = "tornado" -version = "6.5.4" +version = "6.5.5" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"}, - {file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"}, - {file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"}, - {file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"}, - {file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"}, - {file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"}, + {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"}, + {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"}, + {file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"}, + {file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"}, + {file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"}, + {file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"}, + {file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"}, + {file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"}, + {file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"}, + {file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"}, ] [[package]] diff --git a/poetry.lock b/poetry.lock index 6865bacdbf..5d39899e02 100644 --- a/poetry.lock +++ b/poetry.lock @@ -13579,24 +13579,22 @@ files = [ [[package]] name = "tornado" -version = "6.5.4" +version = "6.5.5" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false python-versions = ">=3.9" groups = ["main", "runtime"] files = [ - {file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"}, - {file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"}, - {file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"}, - {file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"}, - {file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"}, - {file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"}, + {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"}, + {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"}, + {file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"}, + {file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"}, + {file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"}, + {file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"}, + {file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"}, + {file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"}, + {file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"}, + {file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"}, ] [[package]] diff --git a/uv.lock b/uv.lock index d4472a0482..bc1aa93840 100644 --- a/uv.lock +++ b/uv.lock @@ -8528,21 +8528,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.4" +version = "6.5.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, - { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, - { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, - { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, - { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, - { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, - { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, ] [[package]] From e2805dea75ce3c9c131835c67a90cab66d839a95 Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Thu, 12 Mar 2026 12:24:06 -0600 Subject: [PATCH 07/92] Fix pagination bug in event_service_base.search_events causing duplicate events in exports (#13364) Co-authored-by: openhands --- .../app_server/event/event_service_base.py | 7 +- .../test_filesystem_event_service.py | 107 ++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/openhands/app_server/event/event_service_base.py b/openhands/app_server/event/event_service_base.py index 41b6066607..50e44665b3 100644 --- a/openhands/app_server/event/event_service_base.py +++ b/openhands/app_server/event/event_service_base.py @@ -106,14 +106,15 @@ class EventServiceBase(EventService, ABC): reverse=(sort_order == EventSortOrder.TIMESTAMP_DESC), ) + # Apply pagination to items (not paths) start_offset = 0 next_page_id = None if page_id: start_offset = int(page_id) - paths = paths[start_offset:] - if len(paths) > limit: - paths = paths[:limit] + items = items[start_offset:] + if len(items) > limit: next_page_id = str(start_offset + limit) + items = items[:limit] return EventPage(items=items, next_page_id=next_page_id) diff --git a/tests/unit/app_server/test_filesystem_event_service.py b/tests/unit/app_server/test_filesystem_event_service.py index c7ca660a58..a382518b57 100644 --- a/tests/unit/app_server/test_filesystem_event_service.py +++ b/tests/unit/app_server/test_filesystem_event_service.py @@ -161,6 +161,113 @@ class TestFilesystemEventServiceSearchEvents: assert hasattr(result, 'next_page_id') assert len(result.items) == 3 + @pytest.mark.asyncio + async def test_search_events_pagination_limits_results( + self, service: FilesystemEventService + ): + """Test that search_events respects the limit parameter for pagination.""" + conversation_id = uuid4() + total_events = 10 + page_limit = 3 + + # Create more events than the limit + for _ in range(total_events): + await service.save_event(conversation_id, create_token_event()) + + # First page should return only 'limit' events + result = await service.search_events(conversation_id, limit=page_limit) + + assert len(result.items) == page_limit + assert result.next_page_id is not None + + @pytest.mark.asyncio + async def test_search_events_pagination_iterates_all_events( + self, service: FilesystemEventService + ): + """Test that pagination correctly iterates through all events without duplicates. + + This test verifies the fix for a bug where pagination was applied to 'paths' + instead of 'items', causing all events to be returned on every page. + """ + conversation_id = uuid4() + total_events = 10 + page_limit = 3 + + # Create events and track their IDs + created_event_ids = set() + for _ in range(total_events): + event = create_token_event() + created_event_ids.add(event.id) + await service.save_event(conversation_id, event) + + # Iterate through all pages and collect event IDs + collected_event_ids = set() + page_id = None + page_count = 0 + + while True: + result = await service.search_events( + conversation_id, page_id=page_id, limit=page_limit + ) + page_count += 1 + + for item in result.items: + # Verify no duplicates - this would fail with the old buggy code + assert item.id not in collected_event_ids, ( + f'Duplicate event {item.id} found on page {page_count}' + ) + collected_event_ids.add(item.id) + + if result.next_page_id is None: + break + page_id = result.next_page_id + + # Verify we got all events exactly once + assert collected_event_ids == created_event_ids + assert len(collected_event_ids) == total_events + + # With 10 events and limit of 3, we should have 4 pages (3+3+3+1) + expected_pages = (total_events + page_limit - 1) // page_limit + assert page_count == expected_pages + + @pytest.mark.asyncio + async def test_search_events_pagination_with_filters( + self, service: FilesystemEventService + ): + """Test that pagination works correctly when combined with filters.""" + conversation_id = uuid4() + + # Create a mix of events + token_events = [create_token_event() for _ in range(5)] + pause_events = [create_pause_event() for _ in range(3)] + + for event in token_events + pause_events: + await service.save_event(conversation_id, event) + + # Search only for token events with pagination + page_limit = 2 + collected_ids = set() + page_id = None + + while True: + result = await service.search_events( + conversation_id, + kind__eq='TokenEvent', + page_id=page_id, + limit=page_limit, + ) + + for item in result.items: + assert item.kind == 'TokenEvent' + collected_ids.add(item.id) + + if result.next_page_id is None: + break + page_id = result.next_page_id + + # Should have found all 5 token events + assert len(collected_ids) == 5 + class TestFilesystemEventServiceIntegration: """Integration tests for FilesystemEventService.""" From c1328f512da72471039d384568ecd93b90a3a112 Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Thu, 12 Mar 2026 13:28:19 -0600 Subject: [PATCH 08/92] Upgrade the SDK to 1.13.0 (#13365) --- enterprise/poetry.lock | 26 +++++++++---------- .../sandbox/sandbox_spec_service.py | 2 +- poetry.lock | 20 +++++++------- pyproject.toml | 12 ++++----- uv.lock | 24 ++++++++--------- 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index ff6f6f3a78..ddf6d37bcc 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -6190,14 +6190,14 @@ 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.12.0" +version = "1.13.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad"}, - {file = "openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d"}, + {file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"}, + {file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"}, ] [package.dependencies] @@ -6214,7 +6214,7 @@ wsproto = ">=1.2.0" [[package]] name = "openhands-ai" -version = "1.4.0" +version = "1.5.0" description = "OpenHands: Code Less, Make More" optional = false python-versions = "^3.12,<3.14" @@ -6259,9 +6259,9 @@ memory-profiler = ">=0.61" numpy = "*" openai = "2.8" openhands-aci = "0.3.3" -openhands-agent-server = "1.12" -openhands-sdk = "1.12" -openhands-tools = "1.12" +openhands-agent-server = "1.13" +openhands-sdk = "1.13" +openhands-tools = "1.13" opentelemetry-api = ">=1.33.1" opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1" pathspec = ">=0.12.1" @@ -6315,14 +6315,14 @@ url = ".." [[package]] name = "openhands-sdk" -version = "1.12.0" +version = "1.13.0" description = "OpenHands SDK - Core functionality for building AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02"}, - {file = "openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9"}, + {file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"}, + {file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"}, ] [package.dependencies] @@ -6345,14 +6345,14 @@ boto3 = ["boto3 (>=1.35.0)"] [[package]] name = "openhands-tools" -version = "1.12.0" +version = "1.13.0" description = "OpenHands Tools - Runtime tools for AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33"}, - {file = "openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c"}, + {file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"}, + {file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"}, ] [package.dependencies] diff --git a/openhands/app_server/sandbox/sandbox_spec_service.py b/openhands/app_server/sandbox/sandbox_spec_service.py index 7fbcb24306..520b5e0322 100644 --- a/openhands/app_server/sandbox/sandbox_spec_service.py +++ b/openhands/app_server/sandbox/sandbox_spec_service.py @@ -13,7 +13,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin # The version of the agent server to use for deployments. # Typically this will be the same as the values from the pyproject.toml -AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.12.0-python' +AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.13.0-python' class SandboxSpecService(ABC): diff --git a/poetry.lock b/poetry.lock index 5d39899e02..50c9298a5a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6367,14 +6367,14 @@ 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.12.0" +version = "1.13.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad"}, - {file = "openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d"}, + {file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"}, + {file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"}, ] [package.dependencies] @@ -6391,14 +6391,14 @@ wsproto = ">=1.2.0" [[package]] name = "openhands-sdk" -version = "1.12.0" +version = "1.13.0" description = "OpenHands SDK - Core functionality for building AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02"}, - {file = "openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9"}, + {file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"}, + {file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"}, ] [package.dependencies] @@ -6421,14 +6421,14 @@ boto3 = ["boto3 (>=1.35.0)"] [[package]] name = "openhands-tools" -version = "1.12.0" +version = "1.13.0" description = "OpenHands Tools - Runtime tools for AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33"}, - {file = "openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c"}, + {file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"}, + {file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"}, ] [package.dependencies] @@ -14846,4 +14846,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api [metadata] lock-version = "2.1" python-versions = "^3.12,<3.14" -content-hash = "7319bfec87aed5ed2803ad7cb947f875e83fa62216b1662a87b9b84078dc03e4" +content-hash = "8988a1da93e30d92a44ff7690ad39ce34a164c3a7b249e0d63a270a505bd52a9" diff --git a/pyproject.toml b/pyproject.toml index 1040e1bd7a..bd01790ff1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,9 +57,9 @@ dependencies = [ "numpy", "openai==2.8", "openhands-aci==0.3.3", - "openhands-agent-server==1.12", - "openhands-sdk==1.12", - "openhands-tools==1.12", + "openhands-agent-server==1.13", + "openhands-sdk==1.13", + "openhands-tools==1.13", "opentelemetry-api>=1.33.1", "opentelemetry-exporter-otlp-proto-grpc>=1.33.1", "pathspec>=0.12.1", @@ -249,9 +249,9 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true } pybase62 = "^1.0.0" # V1 dependencies -openhands-sdk = "1.12" -openhands-agent-server = "1.12" -openhands-tools = "1.12" +openhands-sdk = "1.13" +openhands-agent-server = "1.13" +openhands-tools = "1.13" jwcrypto = ">=1.5.6" sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" } pg8000 = "^1.31.5" diff --git a/uv.lock b/uv.lock index bc1aa93840..0b4aaa8f30 100644 --- a/uv.lock +++ b/uv.lock @@ -3642,7 +3642,7 @@ wheels = [ [[package]] name = "openhands-agent-server" -version = "1.12.0" +version = "1.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -3656,9 +3656,9 @@ dependencies = [ { name = "websockets" }, { name = "wsproto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/18/d76d977201ec93faf22d6cc979b5c9953a0b554bf3294cdb3186d48a5d5a/openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d", size = 72715, upload-time = "2026-03-05T19:22:23.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/d0/419756ad3368e7ab47c07111dfb4bf40073c110817914e09553b8e056fe8/openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a", size = 73594, upload-time = "2026-03-10T18:41:25.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/47/dc31d7ffd6f6687ce4cc0114e01cf1f7f13f9ba841cd47dac5a983e57fb9/openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad", size = 87800, upload-time = "2026-03-05T19:22:27.857Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e1/77b9b3181e6cba89c601533757d148f911416ff968a4ea5fe0882d479ccf/openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29", size = 88607, upload-time = "2026-03-10T18:41:18.321Z" }, ] [[package]] @@ -3826,9 +3826,9 @@ requires-dist = [ { name = "numpy" }, { name = "openai", specifier = "==2.8" }, { name = "openhands-aci", specifier = "==0.3.3" }, - { name = "openhands-agent-server", specifier = "==1.12" }, - { name = "openhands-sdk", specifier = "==1.12" }, - { name = "openhands-tools", specifier = "==1.12" }, + { name = "openhands-agent-server", specifier = "==1.13" }, + { name = "openhands-sdk", specifier = "==1.13" }, + { name = "openhands-tools", specifier = "==1.13" }, { name = "opentelemetry-api", specifier = ">=1.33.1" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.33.1" }, { name = "pathspec", specifier = ">=0.12.1" }, @@ -3906,7 +3906,7 @@ test = [ [[package]] name = "openhands-sdk" -version = "1.12.0" +version = "1.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "agent-client-protocol" }, @@ -3923,14 +3923,14 @@ dependencies = [ { name = "tenacity" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/44/715dd4c43e1a4ba2c47ebd251240dd6aca0dd604cc1354932f0344f93b40/openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9", size = 323133, upload-time = "2026-03-05T19:22:26.623Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/5e35e99252f16c3e9b8eec843b7054ed7d3ad9fadcc0b40064ab3de55469/openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c", size = 330526, upload-time = "2026-03-10T18:41:19.513Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/2f/b7ba4f261d806aaab46f372d2049503ccedde373bb0648b88ebce58ebfe7/openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02", size = 411337, upload-time = "2026-03-05T19:22:29.369Z" }, + { url = "https://files.pythonhosted.org/packages/12/b1/31737964179a8e5a0ed1d0485082a703e2d4cd346701ab4a383ddf33eebb/openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185", size = 420504, upload-time = "2026-03-10T18:41:24.224Z" }, ] [[package]] name = "openhands-tools" -version = "1.12.0" +version = "1.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bashlex" }, @@ -3943,9 +3943,9 @@ dependencies = [ { name = "pydantic" }, { name = "tom-swe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/84/9552e75326c341707d36f7a86ba9a55a8fcb48bfd97e4d1ebe989260fdd8/openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c", size = 110293, upload-time = "2026-03-05T19:22:23.906Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/91/0af0f29dc0da57e7df13bd1653eff80d5c47b8311c6825568837d6ba2af7/openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d", size = 111922, upload-time = "2026-03-10T18:41:26.872Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/26/70031063c81bb1215f5a5d85c33c4e62e6a3d318dd8e3609e5ce68040faa/openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33", size = 150468, upload-time = "2026-03-05T19:22:24.974Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e7/44d677fdd73f249c9bc8a76d2a32848ed96f54324b7d4b0589bb70f7d4e8/openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68", size = 152193, upload-time = "2026-03-10T18:41:20.563Z" }, ] [[package]] From 8b8ed5be96674f236a4f2028e26491dfa62a2d14 Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Thu, 12 Mar 2026 19:08:04 -0600 Subject: [PATCH 09/92] fix: Revert on_conversation_update to load conversation inside method (#13368) Co-authored-by: openhands --- .../event_callback/webhook_router.py | 4 +- .../app_server/test_webhook_router_auth.py | 91 ++++++++++- ...test_webhook_router_parent_conversation.py | 141 +++++++++--------- 3 files changed, 164 insertions(+), 72 deletions(-) diff --git a/openhands/app_server/event_callback/webhook_router.py b/openhands/app_server/event_callback/webhook_router.py index 70f1a2a203..3b4d6a7c5e 100644 --- a/openhands/app_server/event_callback/webhook_router.py +++ b/openhands/app_server/event_callback/webhook_router.py @@ -128,10 +128,12 @@ async def valid_conversation( async def on_conversation_update( conversation_info: ConversationInfo, sandbox_info: SandboxInfo = Depends(valid_sandbox), - existing: AppConversationInfo = Depends(valid_conversation), app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency, ) -> Success: """Webhook callback for when a conversation starts, pauses, resumes, or deletes.""" + existing = await valid_conversation( + conversation_info.id, sandbox_info, app_conversation_info_service + ) # If the conversation is being deleted, no action is required... # Later we may consider deleting the conversation if it exists... diff --git a/tests/unit/app_server/test_webhook_router_auth.py b/tests/unit/app_server/test_webhook_router_auth.py index b2ba0a0626..3d6880b850 100644 --- a/tests/unit/app_server/test_webhook_router_auth.py +++ b/tests/unit/app_server/test_webhook_router_auth.py @@ -8,8 +8,12 @@ from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest -from fastapi import HTTPException, status +from fastapi import FastAPI, HTTPException, status +from fastapi.testclient import TestClient +from openhands.app_server.event_callback.webhook_router import ( + router as webhook_router, +) from openhands.app_server.event_callback.webhook_router import ( valid_conversation, valid_sandbox, @@ -531,3 +535,88 @@ class TestWebhookAuthenticationIntegration: sandbox_info=sandbox_result, app_conversation_info_service=mock_conversation_service, ) + + +class TestWebhookRouterHTTPIntegration: + """Integration tests for webhook router HTTP layer. + + These tests validate that FastAPI routing correctly extracts conversation_id + from the request body rather than requiring it as a query parameter. + """ + + def test_conversation_update_endpoint_does_not_require_query_param(self): + """Test that /webhooks/conversations endpoint accepts conversation_id in body only. + + This test validates the fix for the regression where the endpoint incorrectly + required conversation_id as a query parameter due to using Depends(valid_conversation). + + The endpoint should: + 1. Accept POST requests without any query parameters + 2. Extract conversation_id from the request body (conversation_info.id) + 3. Return 401 (not 422) when auth fails, proving the request was parsed correctly + """ + # Create a minimal FastAPI app with just the webhook router + app = FastAPI() + app.include_router(webhook_router, prefix='/api/v1') + + client = TestClient(app, raise_server_exceptions=False) + + # Create a valid request body with conversation_id in it + conversation_id = str(uuid4()) + request_body = { + 'id': conversation_id, + 'execution_status': 'running', + 'agent': { + 'llm': { + 'model': 'gpt-4', + }, + }, + 'stats': { + 'usage_to_metrics': {}, + }, + } + + # POST to /webhooks/conversations WITHOUT any query parameters + # If the old bug existed (conversation_id required as query param), + # FastAPI would return 422 Unprocessable Entity + response = client.post( + '/api/v1/webhooks/conversations', + json=request_body, + # No X-Session-API-Key header - should fail auth but NOT validation + ) + + # We expect 401 Unauthorized (missing session API key) + # NOT 422 Unprocessable Entity (which would indicate conversation_id + # was incorrectly required as a query parameter) + assert response.status_code == status.HTTP_401_UNAUTHORIZED, ( + f'Expected 401 (auth failure), got {response.status_code}. ' + f'If 422, the endpoint incorrectly requires conversation_id as query param. ' + f'Response: {response.json()}' + ) + assert response.json()['detail'] == 'X-Session-API-Key header is required' + + def test_events_endpoint_still_requires_conversation_id_in_path(self): + """Test that /webhooks/events/{conversation_id} correctly requires path param. + + This ensures we didn't accidentally break the events endpoint which legitimately + requires conversation_id as a path parameter. + """ + # Create a minimal FastAPI app with just the webhook router + app = FastAPI() + app.include_router(webhook_router, prefix='/api/v1') + + client = TestClient(app, raise_server_exceptions=False) + + conversation_id = str(uuid4()) + request_body = [] # Empty events list + + # POST to /webhooks/events/{conversation_id} with path parameter + response = client.post( + f'/api/v1/webhooks/events/{conversation_id}', + json=request_body, + # No X-Session-API-Key header - should fail auth but NOT validation + ) + + # We expect 401 Unauthorized (missing session API key) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json()['detail'] == 'X-Session-API-Key header is required' diff --git a/tests/unit/app_server/test_webhook_router_parent_conversation.py b/tests/unit/app_server/test_webhook_router_parent_conversation.py index 23ae6a0612..4ad846f7f2 100644 --- a/tests/unit/app_server/test_webhook_router_parent_conversation.py +++ b/tests/unit/app_server/test_webhook_router_parent_conversation.py @@ -5,7 +5,7 @@ conversations are updated via the on_conversation_update webhook endpoint. """ from typing import AsyncGenerator -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest @@ -19,6 +19,7 @@ from openhands.app_server.app_conversation.app_conversation_models import ( from openhands.app_server.app_conversation.sql_app_conversation_info_service import ( SQLAppConversationInfoService, ) +from openhands.app_server.event_callback.webhook_router import on_conversation_update from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus from openhands.app_server.user.specifiy_user_context import SpecifyUserContext from openhands.app_server.utils.sql_utils import Base @@ -118,9 +119,6 @@ class TestOnConversationUpdateParentConversationId: Assert: - Saved conversation retains the parent_conversation_id """ - from openhands.app_server.event_callback.webhook_router import ( - on_conversation_update, - ) # Arrange parent_id = uuid4() @@ -137,13 +135,16 @@ class TestOnConversationUpdateParentConversationId: parent_conversation_id=parent_id, ) - # Act - call on_conversation_update directly with dependencies - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - existing=existing_conv, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with mocked valid_conversation + with patch( + 'openhands.app_server.event_callback.webhook_router.valid_conversation', + return_value=existing_conv, + ): + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + app_conversation_info_service=app_conversation_info_service, + ) # Assert assert isinstance(result, Success) @@ -171,9 +172,6 @@ class TestOnConversationUpdateParentConversationId: Assert: - Saved conversation has parent_conversation_id as None """ - from openhands.app_server.event_callback.webhook_router import ( - on_conversation_update, - ) # Arrange conversation_id = mock_conversation_info.id @@ -187,13 +185,16 @@ class TestOnConversationUpdateParentConversationId: parent_conversation_id=None, ) - # Act - call on_conversation_update directly with dependencies - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - existing=existing_conv, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with mocked valid_conversation + with patch( + 'openhands.app_server.event_callback.webhook_router.valid_conversation', + return_value=existing_conv, + ): + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + app_conversation_info_service=app_conversation_info_service, + ) # Assert assert isinstance(result, Success) @@ -220,9 +221,6 @@ class TestOnConversationUpdateParentConversationId: Assert: - New conversation has parent_conversation_id as None """ - from openhands.app_server.event_callback.webhook_router import ( - on_conversation_update, - ) # Arrange conversation_id = mock_conversation_info.id @@ -234,13 +232,16 @@ class TestOnConversationUpdateParentConversationId: created_by_user_id=sandbox_info.created_by_user_id, ) - # Act - call on_conversation_update directly with dependencies - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - existing=stub_conv, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with mocked valid_conversation + with patch( + 'openhands.app_server.event_callback.webhook_router.valid_conversation', + return_value=stub_conv, + ): + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + app_conversation_info_service=app_conversation_info_service, + ) # Assert assert isinstance(result, Success) @@ -268,9 +269,6 @@ class TestOnConversationUpdateParentConversationId: Assert: - All metadata including parent_conversation_id is preserved """ - from openhands.app_server.event_callback.webhook_router import ( - on_conversation_update, - ) # Arrange parent_id = uuid4() @@ -290,13 +288,16 @@ class TestOnConversationUpdateParentConversationId: parent_conversation_id=parent_id, ) - # Act - call on_conversation_update directly with dependencies - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - existing=existing_conv, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with mocked valid_conversation + with patch( + 'openhands.app_server.event_callback.webhook_router.valid_conversation', + return_value=existing_conv, + ): + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + app_conversation_info_service=app_conversation_info_service, + ) # Assert assert isinstance(result, Success) @@ -333,9 +334,6 @@ class TestOnConversationUpdateParentConversationId: Assert: - Parent_conversation_id remains unchanged after all updates """ - from openhands.app_server.event_callback.webhook_router import ( - on_conversation_update, - ) # Arrange parent_id = uuid4() @@ -363,12 +361,15 @@ class TestOnConversationUpdateParentConversationId: else: existing = initial_conv - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - existing=existing, - app_conversation_info_service=app_conversation_info_service, - ) + with patch( + 'openhands.app_server.event_callback.webhook_router.valid_conversation', + return_value=existing, + ): + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + app_conversation_info_service=app_conversation_info_service, + ) assert isinstance(result, Success) # Assert @@ -396,9 +397,6 @@ class TestOnConversationUpdateParentConversationId: Assert: - Function returns early, no updates are made """ - from openhands.app_server.event_callback.webhook_router import ( - on_conversation_update, - ) # Arrange parent_id = uuid4() @@ -420,13 +418,16 @@ class TestOnConversationUpdateParentConversationId: # Set conversation to DELETING status mock_conversation_info.execution_status = ConversationExecutionStatus.DELETING - # Act - call on_conversation_update directly with dependencies - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - existing=existing_conv, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with mocked valid_conversation + with patch( + 'openhands.app_server.event_callback.webhook_router.valid_conversation', + return_value=existing_conv, + ): + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + app_conversation_info_service=app_conversation_info_service, + ) # Assert - Function returns success but doesn't update assert isinstance(result, Success) @@ -456,9 +457,6 @@ class TestOnConversationUpdateParentConversationId: Assert: - Parent_conversation_id is preserved and title is generated """ - from openhands.app_server.event_callback.webhook_router import ( - on_conversation_update, - ) # Arrange parent_id = uuid4() @@ -473,13 +471,16 @@ class TestOnConversationUpdateParentConversationId: parent_conversation_id=parent_id, ) - # Act - call on_conversation_update directly with dependencies - result = await on_conversation_update( - conversation_info=mock_conversation_info, - sandbox_info=sandbox_info, - existing=existing_conv, - app_conversation_info_service=app_conversation_info_service, - ) + # Act - call on_conversation_update directly with mocked valid_conversation + with patch( + 'openhands.app_server.event_callback.webhook_router.valid_conversation', + return_value=existing_conv, + ): + result = await on_conversation_update( + conversation_info=mock_conversation_info, + sandbox_info=sandbox_info, + app_conversation_info_service=app_conversation_info_service, + ) # Assert assert isinstance(result, Success) From 8799c0702736030130b7ff10ee3ae32f28ecd465 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Fri, 13 Mar 2026 14:35:22 +0800 Subject: [PATCH 10/92] fix: add PR creation instructions to V1 issue comment template and fix summary prompt (#13377) Co-authored-by: openhands --- .../resolver/github/issue_comment_initial_message.j2 | 11 +++++++++++ .../integrations/templates/resolver/summary_prompt.j2 | 5 +++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/openhands/integrations/templates/resolver/github/issue_comment_initial_message.j2 b/openhands/integrations/templates/resolver/github/issue_comment_initial_message.j2 index 69b321aca4..95c9062c74 100644 --- a/openhands/integrations/templates/resolver/github/issue_comment_initial_message.j2 +++ b/openhands/integrations/templates/resolver/github/issue_comment_initial_message.j2 @@ -29,3 +29,14 @@ For reference, here are the previous comments on the issue: # Final Checklist Re-read the issue title, body, and comments and make sure that you have successfully implemented all requirements. + +Use the $GITHUB_TOKEN and GitHub APIs to + +1. Create a new branch using `openhands/` as a prefix (e.g `openhands/update-readme`) +2. Commit your changes with a clear commit message +3. Push the branch to GitHub +4. Use the `create_pr` tool to open a new PR +5. The PR description should: + - Follow the repository's PR template (check `.github/pull_request_template.md` if it exists) + - Mention that it "fixes" or "closes" the issue number + - Include all required sections from the template diff --git a/openhands/integrations/templates/resolver/summary_prompt.j2 b/openhands/integrations/templates/resolver/summary_prompt.j2 index 64f5442322..5250a6e519 100644 --- a/openhands/integrations/templates/resolver/summary_prompt.j2 +++ b/openhands/integrations/templates/resolver/summary_prompt.j2 @@ -2,8 +2,9 @@ Please send a final message summarizing your work. If you simply answered a question, this final message should re-state the answer to the question. -If you made changes, please first double-check the git diff, think carefully about the user's request(s), and check: +If you made changes, please think carefully about the user's request(s) and summarize: 1. whether the request has been completely addressed and all of the instructions have been followed faithfully (in checklist format if appropriate). 2. whether the changes are concise (if there are any extraneous changes not important to addressing the user's request they should be reverted). 3. focus only on summarizing new changes since your last summary, avoiding repetition of information already covered in previous summaries. -If the request has been addressed and the changes are concise, then push your changes to the remote branch and send a final message summarizing the changes. + +Please summarize in whatever language the user is using. From e82bf443240f00eff27472562ea12f93da16418d Mon Sep 17 00:00:00 2001 From: aivong-openhands Date: Fri, 13 Mar 2026 06:58:56 -0500 Subject: [PATCH 11/92] Fix CVE-2025-67221: Update orjson to 3.11.6+ (#13371) Co-authored-by: OpenHands CVE Fix Bot --- poetry.lock | 167 +++++++++++++++++++++++-------------------------- pyproject.toml | 2 + uv.lock | 66 +++++++++---------- 3 files changed, 113 insertions(+), 122 deletions(-) diff --git a/poetry.lock b/poetry.lock index 50c9298a5a..8b53c040e9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6636,99 +6636,86 @@ files = [ [[package]] name = "orjson" -version = "3.11.5" +version = "3.11.7" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401"}, - {file = "orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8"}, - {file = "orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167"}, - {file = "orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8"}, - {file = "orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880"}, - {file = "orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d"}, - {file = "orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1"}, - {file = "orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c"}, - {file = "orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d"}, - {file = "orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca"}, - {file = "orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98"}, - {file = "orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875"}, - {file = "orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe"}, - {file = "orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629"}, - {file = "orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05"}, - {file = "orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef"}, - {file = "orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583"}, - {file = "orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287"}, - {file = "orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0"}, - {file = "orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439"}, - {file = "orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499"}, - {file = "orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310"}, - {file = "orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5"}, - {file = "orjson-3.11.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1b280e2d2d284a6713b0cfec7b08918ebe57df23e3f76b27586197afca3cb1e9"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d8a112b274fae8c5f0f01954cb0480137072c271f3f4958127b010dfefaec"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0a2ae6f09ac7bd47d2d5a5305c1d9ed08ac057cda55bb0a49fa506f0d2da00"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0d87bd1896faac0d10b4f849016db81a63e4ec5df38757ffae84d45ab38aa71"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:801a821e8e6099b8c459ac7540b3c32dba6013437c57fdcaec205b169754f38c"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a0f6ac618c98c74b7fbc8c0172ba86f9e01dbf9f62aa0b1776c2231a7bffe5"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea7339bdd22e6f1060c55ac31b6a755d86a5b2ad3657f2669ec243f8e3b2bdb"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4dad582bc93cef8f26513e12771e76385a7e6187fd713157e971c784112aad56"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0522003e9f7fba91982e83a97fec0708f5a714c96c4209db7104e6b9d132f111"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7403851e430a478440ecc1258bcbacbfbd8175f9ac1e39031a7121dd0de05ff8"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5f691263425d3177977c8d1dd896cde7b98d93cbf390b2544a090675e83a6a0a"}, - {file = "orjson-3.11.5-cp39-cp39-win32.whl", hash = "sha256:61026196a1c4b968e1b1e540563e277843082e9e97d78afa03eb89315af531f1"}, - {file = "orjson-3.11.5-cp39-cp39-win_amd64.whl", hash = "sha256:09b94b947ac08586af635ef922d69dc9bc63321527a3a04647f4986a73f4bd30"}, - {file = "orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5"}, + {file = "orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa"}, + {file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e"}, + {file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2"}, + {file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c"}, + {file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f"}, + {file = "orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de"}, + {file = "orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993"}, + {file = "orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c"}, + {file = "orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960"}, + {file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8"}, + {file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504"}, + {file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e"}, + {file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561"}, + {file = "orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d"}, + {file = "orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471"}, + {file = "orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d"}, + {file = "orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f"}, + {file = "orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad"}, + {file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867"}, + {file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d"}, + {file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab"}, + {file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2"}, + {file = "orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f"}, + {file = "orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74"}, + {file = "orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5"}, + {file = "orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733"}, + {file = "orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace"}, + {file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b"}, + {file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157"}, + {file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3"}, + {file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223"}, + {file = "orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3"}, + {file = "orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757"}, + {file = "orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539"}, + {file = "orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0"}, + {file = "orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1"}, + {file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183"}, + {file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650"}, + {file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141"}, + {file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2"}, + {file = "orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576"}, + {file = "orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1"}, + {file = "orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d"}, + {file = "orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49"}, ] [[package]] @@ -14846,4 +14833,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api [metadata] lock-version = "2.1" python-versions = "^3.12,<3.14" -content-hash = "8988a1da93e30d92a44ff7690ad39ce34a164c3a7b249e0d63a270a505bd52a9" +content-hash = "a2b101c19697e385c325c88f5ab454a5c43133469fc01eef856010da1fa420d3" diff --git a/pyproject.toml b/pyproject.toml index bd01790ff1..fc8b21e368 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ dependencies = [ "openhands-tools==1.13", "opentelemetry-api>=1.33.1", "opentelemetry-exporter-otlp-proto-grpc>=1.33.1", + "orjson>=3.11.6", "pathspec>=0.12.1", "pexpect", "pg8000>=1.31.5", @@ -163,6 +164,7 @@ include = [ [tool.poetry.dependencies] python = "^3.12,<3.14" authlib = ">=1.6.7" # Pinned to fix CVE-2026-28802 +orjson = ">=3.11.6" # Pinned to fix CVE-2025-67221 litellm = ">=1.74.3, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272) openai = "2.8.0" # Pin due to litellm incompatibility with >=1.100.0 (BerriAI/litellm#13711) aiohttp = ">=3.13.3" # Pin to avoid CVE-2025-69223 (vulnerable versions < 3.13.3) diff --git a/uv.lock b/uv.lock index 0b4aaa8f30..11f13182df 100644 --- a/uv.lock +++ b/uv.lock @@ -3706,6 +3706,7 @@ dependencies = [ { name = "openhands-tools" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "orjson" }, { name = "pathspec" }, { name = "pexpect" }, { name = "pg8000" }, @@ -3831,6 +3832,7 @@ requires-dist = [ { name = "openhands-tools", specifier = "==1.13" }, { name = "opentelemetry-api", specifier = ">=1.33.1" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.33.1" }, + { name = "orjson", specifier = ">=3.11.6" }, { name = "pathspec", specifier = ">=0.12.1" }, { name = "pexpect" }, { name = "pg8000", specifier = ">=1.31.5" }, @@ -4100,40 +4102,40 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.5" +version = "3.11.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" }, - { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" }, - { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" }, - { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" }, - { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" }, - { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" }, - { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" }, - { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" }, - { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" }, - { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" }, - { url = "https://files.pythonhosted.org/packages/10/43/61a77040ce59f1569edf38f0b9faadc90c8cf7e9bec2e0df51d0132c6bb7/orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629", size = 245271, upload-time = "2025-12-06T15:54:40.878Z" }, - { url = "https://files.pythonhosted.org/packages/55/f9/0f79be617388227866d50edd2fd320cb8fb94dc1501184bb1620981a0aba/orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3", size = 129422, upload-time = "2025-12-06T15:54:42.403Z" }, - { url = "https://files.pythonhosted.org/packages/77/42/f1bf1549b432d4a78bfa95735b79b5dac75b65b5bb815bba86ad406ead0a/orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39", size = 132060, upload-time = "2025-12-06T15:54:43.531Z" }, - { url = "https://files.pythonhosted.org/packages/25/49/825aa6b929f1a6ed244c78acd7b22c1481fd7e5fda047dc8bf4c1a807eb6/orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f", size = 130391, upload-time = "2025-12-06T15:54:45.059Z" }, - { url = "https://files.pythonhosted.org/packages/42/ec/de55391858b49e16e1aa8f0bbbb7e5997b7345d8e984a2dec3746d13065b/orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51", size = 135964, upload-time = "2025-12-06T15:54:46.576Z" }, - { url = "https://files.pythonhosted.org/packages/1c/40/820bc63121d2d28818556a2d0a09384a9f0262407cf9fa305e091a8048df/orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8", size = 139817, upload-time = "2025-12-06T15:54:48.084Z" }, - { url = "https://files.pythonhosted.org/packages/09/c7/3a445ca9a84a0d59d26365fd8898ff52bdfcdcb825bcc6519830371d2364/orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706", size = 137336, upload-time = "2025-12-06T15:54:49.426Z" }, - { url = "https://files.pythonhosted.org/packages/9a/b3/dc0d3771f2e5d1f13368f56b339c6782f955c6a20b50465a91acb79fe961/orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f", size = 138993, upload-time = "2025-12-06T15:54:50.939Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a2/65267e959de6abe23444659b6e19c888f242bf7725ff927e2292776f6b89/orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863", size = 141070, upload-time = "2025-12-06T15:54:52.414Z" }, - { url = "https://files.pythonhosted.org/packages/63/c9/da44a321b288727a322c6ab17e1754195708786a04f4f9d2220a5076a649/orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228", size = 413505, upload-time = "2025-12-06T15:54:53.67Z" }, - { url = "https://files.pythonhosted.org/packages/7f/17/68dc14fa7000eefb3d4d6d7326a190c99bb65e319f02747ef3ebf2452f12/orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2", size = 151342, upload-time = "2025-12-06T15:54:55.113Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c5/ccee774b67225bed630a57478529fc026eda33d94fe4c0eac8fe58d4aa52/orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05", size = 141823, upload-time = "2025-12-06T15:54:56.331Z" }, - { url = "https://files.pythonhosted.org/packages/67/80/5d00e4155d0cd7390ae2087130637671da713959bb558db9bac5e6f6b042/orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef", size = 135236, upload-time = "2025-12-06T15:54:57.507Z" }, - { url = "https://files.pythonhosted.org/packages/95/fe/792cc06a84808dbdc20ac6eab6811c53091b42f8e51ecebf14b540e9cfe4/orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583", size = 133167, upload-time = "2025-12-06T15:54:58.71Z" }, - { url = "https://files.pythonhosted.org/packages/46/2c/d158bd8b50e3b1cfdcf406a7e463f6ffe3f0d167b99634717acdaf5e299f/orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287", size = 126712, upload-time = "2025-12-06T15:54:59.892Z" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, ] [[package]] From 2c7b25ab1c6d453ff74b90064a6cbbf9da7ad679 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:28:15 +0700 Subject: [PATCH 12/92] fix(frontend): address the responsive issue on the home page (#13353) --- frontend/src/components/features/sidebar/sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index 3fd9496176..f7b11cf2d7 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -77,7 +77,7 @@ export function Sidebar() {
-
+
Date: Fri, 13 Mar 2026 21:28:38 +0700 Subject: [PATCH 13/92] fix(frontend): address the responsive issue on the integrations page (#13354) --- .../features/settings/project-management/integration-row.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/features/settings/project-management/integration-row.tsx b/frontend/src/components/features/settings/project-management/integration-row.tsx index 02310be922..1fd3d8dc61 100644 --- a/frontend/src/components/features/settings/project-management/integration-row.tsx +++ b/frontend/src/components/features/settings/project-management/integration-row.tsx @@ -85,7 +85,10 @@ export function IntegrationRow({ : t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL); return ( -
+
{platformName}
Date: Fri, 13 Mar 2026 09:30:16 -0600 Subject: [PATCH 14/92] Add sandbox_id__eq filter parameter to search/count conversation methods (#13385) Co-authored-by: openhands --- .../saas_app_conversation_info_injector.py | 8 + ..._saas_sql_app_conversation_info_service.py | 199 ++++++++++++++ .../app_conversation_info_service.py | 2 + .../sql_app_conversation_info_service.py | 8 + .../test_sql_app_conversation_info_service.py | 251 ++++++++++++++++++ 5 files changed, 468 insertions(+) diff --git a/enterprise/server/utils/saas_app_conversation_info_injector.py b/enterprise/server/utils/saas_app_conversation_info_injector.py index 16a7952a33..987f42ca10 100644 --- a/enterprise/server/utils/saas_app_conversation_info_injector.py +++ b/enterprise/server/utils/saas_app_conversation_info_injector.py @@ -119,6 +119,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC, page_id: str | None = None, limit: int = 100, @@ -141,6 +142,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService): created_at__lt=created_at__lt, updated_at__gte=updated_at__gte, updated_at__lt=updated_at__lt, + sandbox_id__eq=sandbox_id__eq, ) # Add sort order @@ -198,6 +200,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, ) -> int: """Count conversations matching the given filters with SAAS metadata.""" query = ( @@ -220,6 +223,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService): created_at__lt=created_at__lt, updated_at__gte=updated_at__gte, updated_at__lt=updated_at__lt, + sandbox_id__eq=sandbox_id__eq, ) result = await self.db_session.execute(query) @@ -234,6 +238,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, ): """Apply filters to query that includes SAAS metadata.""" # Apply the same filters as the base class @@ -259,6 +264,9 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService): StoredConversationMetadata.last_updated_at < updated_at__lt ) + if sandbox_id__eq is not None: + conditions.append(StoredConversationMetadata.sandbox_id == sandbox_id__eq) + if conditions: query = query.where(*conditions) return query diff --git a/enterprise/tests/unit/storage/test_saas_sql_app_conversation_info_service.py b/enterprise/tests/unit/storage/test_saas_sql_app_conversation_info_service.py index 2ce5e3599c..0e0d1e9d35 100644 --- a/enterprise/tests/unit/storage/test_saas_sql_app_conversation_info_service.py +++ b/enterprise/tests/unit/storage/test_saas_sql_app_conversation_info_service.py @@ -791,3 +791,202 @@ class TestSaasSQLAppConversationInfoServiceWebhookFallback: assert len(user1_page.items) == 1 assert user1_page.items[0].id == conv_id assert user1_page.items[0].title == 'E2E Webhook Conversation' + + +class TestSandboxIdFilterSaas: + """Test suite for sandbox_id__eq filter parameter in SAAS service.""" + + @pytest.mark.asyncio + async def test_search_by_sandbox_id( + self, + async_session_with_users: AsyncSession, + ): + """Test searching conversations by exact sandbox_id match with SAAS user filtering.""" + # Create service for user1 + user1_service = SaasSQLAppConversationInfoService( + db_session=async_session_with_users, + user_context=SpecifyUserContext(user_id=str(USER1_ID)), + ) + + # Create conversations with different sandbox IDs for user1 + conv1 = AppConversationInfo( + id=uuid4(), + created_by_user_id=str(USER1_ID), + sandbox_id='sandbox_alpha', + title='Conversation Alpha', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + conv2 = AppConversationInfo( + id=uuid4(), + created_by_user_id=str(USER1_ID), + sandbox_id='sandbox_beta', + title='Conversation Beta', + created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc), + ) + conv3 = AppConversationInfo( + id=uuid4(), + created_by_user_id=str(USER1_ID), + sandbox_id='sandbox_alpha', + title='Conversation Gamma', + created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc), + ) + + # Save all conversations + await user1_service.save_app_conversation_info(conv1) + await user1_service.save_app_conversation_info(conv2) + await user1_service.save_app_conversation_info(conv3) + + # Search for sandbox_alpha - should return 2 conversations + page = await user1_service.search_app_conversation_info( + sandbox_id__eq='sandbox_alpha' + ) + assert len(page.items) == 2 + sandbox_ids = {item.sandbox_id for item in page.items} + assert sandbox_ids == {'sandbox_alpha'} + conversation_ids = {item.id for item in page.items} + assert conv1.id in conversation_ids + assert conv3.id in conversation_ids + + # Search for sandbox_beta - should return 1 conversation + page = await user1_service.search_app_conversation_info( + sandbox_id__eq='sandbox_beta' + ) + assert len(page.items) == 1 + assert page.items[0].id == conv2.id + + # Search for non-existent sandbox - should return 0 conversations + page = await user1_service.search_app_conversation_info( + sandbox_id__eq='sandbox_nonexistent' + ) + assert len(page.items) == 0 + + @pytest.mark.asyncio + async def test_count_by_sandbox_id( + self, + async_session_with_users: AsyncSession, + ): + """Test counting conversations by exact sandbox_id match with SAAS user filtering.""" + # Create service for user1 + user1_service = SaasSQLAppConversationInfoService( + db_session=async_session_with_users, + user_context=SpecifyUserContext(user_id=str(USER1_ID)), + ) + + # Create conversations with different sandbox IDs + conv1 = AppConversationInfo( + id=uuid4(), + created_by_user_id=str(USER1_ID), + sandbox_id='sandbox_x', + title='Conversation X1', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + conv2 = AppConversationInfo( + id=uuid4(), + created_by_user_id=str(USER1_ID), + sandbox_id='sandbox_y', + title='Conversation Y1', + created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc), + ) + conv3 = AppConversationInfo( + id=uuid4(), + created_by_user_id=str(USER1_ID), + sandbox_id='sandbox_x', + title='Conversation X2', + created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc), + ) + + # Save all conversations + await user1_service.save_app_conversation_info(conv1) + await user1_service.save_app_conversation_info(conv2) + await user1_service.save_app_conversation_info(conv3) + + # Count for sandbox_x - should be 2 + count = await user1_service.count_app_conversation_info( + sandbox_id__eq='sandbox_x' + ) + assert count == 2 + + # Count for sandbox_y - should be 1 + count = await user1_service.count_app_conversation_info( + sandbox_id__eq='sandbox_y' + ) + assert count == 1 + + # Count for non-existent sandbox - should be 0 + count = await user1_service.count_app_conversation_info( + sandbox_id__eq='sandbox_nonexistent' + ) + assert count == 0 + + @pytest.mark.asyncio + async def test_sandbox_id_filter_respects_user_isolation( + self, + async_session_with_users: AsyncSession, + ): + """Test that sandbox_id filter respects user isolation in SAAS environment.""" + # Create services for both users + user1_service = SaasSQLAppConversationInfoService( + db_session=async_session_with_users, + user_context=SpecifyUserContext(user_id=str(USER1_ID)), + ) + user2_service = SaasSQLAppConversationInfoService( + db_session=async_session_with_users, + user_context=SpecifyUserContext(user_id=str(USER2_ID)), + ) + + # Create conversation with same sandbox_id for both users + shared_sandbox_id = 'sandbox_shared' + + conv_user1 = AppConversationInfo( + id=uuid4(), + created_by_user_id=str(USER1_ID), + sandbox_id=shared_sandbox_id, + title='User1 Conversation', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + conv_user2 = AppConversationInfo( + id=uuid4(), + created_by_user_id=str(USER2_ID), + sandbox_id=shared_sandbox_id, + title='User2 Conversation', + created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc), + ) + + # Save conversations + await user1_service.save_app_conversation_info(conv_user1) + await user2_service.save_app_conversation_info(conv_user2) + + # User1 should only see their own conversation with this sandbox_id + page = await user1_service.search_app_conversation_info( + sandbox_id__eq=shared_sandbox_id + ) + assert len(page.items) == 1 + assert page.items[0].id == conv_user1.id + assert page.items[0].title == 'User1 Conversation' + + # User2 should only see their own conversation with this sandbox_id + page = await user2_service.search_app_conversation_info( + sandbox_id__eq=shared_sandbox_id + ) + assert len(page.items) == 1 + assert page.items[0].id == conv_user2.id + assert page.items[0].title == 'User2 Conversation' + + # Count should also respect user isolation + count = await user1_service.count_app_conversation_info( + sandbox_id__eq=shared_sandbox_id + ) + assert count == 1 + + count = await user2_service.count_app_conversation_info( + sandbox_id__eq=shared_sandbox_id + ) + assert count == 1 diff --git a/openhands/app_server/app_conversation/app_conversation_info_service.py b/openhands/app_server/app_conversation/app_conversation_info_service.py index 8e9f1ffe68..bb83ab5801 100644 --- a/openhands/app_server/app_conversation/app_conversation_info_service.py +++ b/openhands/app_server/app_conversation/app_conversation_info_service.py @@ -24,6 +24,7 @@ class AppConversationInfoService(ABC): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC, page_id: str | None = None, limit: int = 100, @@ -39,6 +40,7 @@ class AppConversationInfoService(ABC): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, ) -> int: """Count sandboxed conversations.""" diff --git a/openhands/app_server/app_conversation/sql_app_conversation_info_service.py b/openhands/app_server/app_conversation/sql_app_conversation_info_service.py index af4528c9a4..c7c9e1935e 100644 --- a/openhands/app_server/app_conversation/sql_app_conversation_info_service.py +++ b/openhands/app_server/app_conversation/sql_app_conversation_info_service.py @@ -119,6 +119,7 @@ class SQLAppConversationInfoService(AppConversationInfoService): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC, page_id: str | None = None, limit: int = 100, @@ -141,6 +142,7 @@ class SQLAppConversationInfoService(AppConversationInfoService): created_at__lt=created_at__lt, updated_at__gte=updated_at__gte, updated_at__lt=updated_at__lt, + sandbox_id__eq=sandbox_id__eq, ) # Add sort order @@ -195,6 +197,7 @@ class SQLAppConversationInfoService(AppConversationInfoService): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, ) -> int: """Count sandboxed conversations matching the given filters.""" query = select(func.count(StoredConversationMetadata.conversation_id)).where( @@ -208,6 +211,7 @@ class SQLAppConversationInfoService(AppConversationInfoService): created_at__lt=created_at__lt, updated_at__gte=updated_at__gte, updated_at__lt=updated_at__lt, + sandbox_id__eq=sandbox_id__eq, ) result = await self.db_session.execute(query) @@ -222,6 +226,7 @@ class SQLAppConversationInfoService(AppConversationInfoService): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, ) -> Select: # Apply the same filters as search_app_conversations conditions = [] @@ -246,6 +251,9 @@ class SQLAppConversationInfoService(AppConversationInfoService): StoredConversationMetadata.last_updated_at < updated_at__lt ) + if sandbox_id__eq is not None: + conditions.append(StoredConversationMetadata.sandbox_id == sandbox_id__eq) + if conditions: query = query.where(*conditions) return query diff --git a/tests/unit/app_server/test_sql_app_conversation_info_service.py b/tests/unit/app_server/test_sql_app_conversation_info_service.py index 2b741d984f..a491fa93af 100644 --- a/tests/unit/app_server/test_sql_app_conversation_info_service.py +++ b/tests/unit/app_server/test_sql_app_conversation_info_service.py @@ -943,3 +943,254 @@ class TestSQLAppConversationInfoService: assert parent_id in all_ids for sub_info in sub_conversations: assert sub_info.id in all_ids + + +class TestSandboxIdFilter: + """Test suite for sandbox_id__eq filter parameter.""" + + @pytest.mark.asyncio + async def test_search_by_sandbox_id( + self, + service: SQLAppConversationInfoService, + ): + """Test searching conversations by exact sandbox_id match.""" + # Create conversations with different sandbox IDs + conv1 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_alpha', + title='Conversation Alpha', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + conv2 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_beta', + title='Conversation Beta', + created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc), + ) + conv3 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_alpha', + title='Conversation Gamma', + created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc), + ) + + # Save all conversations + await service.save_app_conversation_info(conv1) + await service.save_app_conversation_info(conv2) + await service.save_app_conversation_info(conv3) + + # Search for sandbox_alpha - should return 2 conversations + page = await service.search_app_conversation_info( + sandbox_id__eq='sandbox_alpha' + ) + assert len(page.items) == 2 + sandbox_ids = {item.sandbox_id for item in page.items} + assert sandbox_ids == {'sandbox_alpha'} + conversation_ids = {item.id for item in page.items} + assert conv1.id in conversation_ids + assert conv3.id in conversation_ids + + # Search for sandbox_beta - should return 1 conversation + page = await service.search_app_conversation_info(sandbox_id__eq='sandbox_beta') + assert len(page.items) == 1 + assert page.items[0].id == conv2.id + assert page.items[0].sandbox_id == 'sandbox_beta' + + # Search for non-existent sandbox - should return 0 conversations + page = await service.search_app_conversation_info( + sandbox_id__eq='sandbox_nonexistent' + ) + assert len(page.items) == 0 + + @pytest.mark.asyncio + async def test_count_by_sandbox_id( + self, + service: SQLAppConversationInfoService, + ): + """Test counting conversations by exact sandbox_id match.""" + # Create conversations with different sandbox IDs + conv1 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_x', + title='Conversation X1', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + conv2 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_y', + title='Conversation Y1', + created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc), + ) + conv3 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_x', + title='Conversation X2', + created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc), + ) + conv4 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_x', + title='Conversation X3', + created_at=datetime(2024, 1, 1, 15, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 15, 30, 0, tzinfo=timezone.utc), + ) + + # Save all conversations + await service.save_app_conversation_info(conv1) + await service.save_app_conversation_info(conv2) + await service.save_app_conversation_info(conv3) + await service.save_app_conversation_info(conv4) + + # Count for sandbox_x - should be 3 + count = await service.count_app_conversation_info(sandbox_id__eq='sandbox_x') + assert count == 3 + + # Count for sandbox_y - should be 1 + count = await service.count_app_conversation_info(sandbox_id__eq='sandbox_y') + assert count == 1 + + # Count for non-existent sandbox - should be 0 + count = await service.count_app_conversation_info( + sandbox_id__eq='sandbox_nonexistent' + ) + assert count == 0 + + @pytest.mark.asyncio + async def test_sandbox_id_filter_combined_with_title_filter( + self, + service: SQLAppConversationInfoService, + ): + """Test sandbox_id filter combined with title filter.""" + # Create conversations with different sandbox IDs and titles + conv1 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_project', + title='Feature: User Authentication', + created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc), + ) + conv2 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_project', + title='Bug Fix: Login Issue', + created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc), + ) + conv3 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_other', + title='Feature: Payment System', + created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc), + ) + + # Save all conversations + await service.save_app_conversation_info(conv1) + await service.save_app_conversation_info(conv2) + await service.save_app_conversation_info(conv3) + + # Search for Feature in sandbox_project - should return 1 + page = await service.search_app_conversation_info( + sandbox_id__eq='sandbox_project', title__contains='Feature' + ) + assert len(page.items) == 1 + assert page.items[0].id == conv1.id + + # Search for Feature in sandbox_other - should return 1 + page = await service.search_app_conversation_info( + sandbox_id__eq='sandbox_other', title__contains='Feature' + ) + assert len(page.items) == 1 + assert page.items[0].id == conv3.id + + # Count for Bug in sandbox_project - should be 1 + count = await service.count_app_conversation_info( + sandbox_id__eq='sandbox_project', title__contains='Bug' + ) + assert count == 1 + + # Count for Bug in sandbox_other - should be 0 + count = await service.count_app_conversation_info( + sandbox_id__eq='sandbox_other', title__contains='Bug' + ) + assert count == 0 + + @pytest.mark.asyncio + async def test_sandbox_id_filter_with_date_filters( + self, + service: SQLAppConversationInfoService, + ): + """Test sandbox_id filter combined with date range filters.""" + base_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + # Create conversations in the same sandbox but at different times + conv1 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_time_test', + title='Conversation Early', + created_at=base_time, + updated_at=base_time, + ) + conv2 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_time_test', + title='Conversation Middle', + created_at=base_time.replace(hour=15), + updated_at=base_time.replace(hour=15), + ) + conv3 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_time_test', + title='Conversation Late', + created_at=base_time.replace(hour=18), + updated_at=base_time.replace(hour=18), + ) + conv4 = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id='sandbox_other_time', + title='Conversation Other', + created_at=base_time.replace(hour=15), + updated_at=base_time.replace(hour=15), + ) + + # Save all conversations + await service.save_app_conversation_info(conv1) + await service.save_app_conversation_info(conv2) + await service.save_app_conversation_info(conv3) + await service.save_app_conversation_info(conv4) + + # Search for sandbox_time_test with date filter - should return 2 + cutoff = base_time.replace(hour=14) + page = await service.search_app_conversation_info( + sandbox_id__eq='sandbox_time_test', created_at__gte=cutoff + ) + assert len(page.items) == 2 + conversation_ids = {item.id for item in page.items} + assert conv2.id in conversation_ids + assert conv3.id in conversation_ids + + # Count for sandbox_time_test with date filter + count = await service.count_app_conversation_info( + sandbox_id__eq='sandbox_time_test', created_at__gte=cutoff + ) + assert count == 2 From cd2d0ee9a547cbf9f04d46ea14a9559890007a97 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:38:54 +0400 Subject: [PATCH 15/92] feat(frontend): Organizational support (#9496) Co-authored-by: openhands Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com> Co-authored-by: Abhay Mishra Co-authored-by: Hyun Han <62870362+smosco@users.noreply.github.com> Co-authored-by: Nhan Nguyen Co-authored-by: Bharath A V Co-authored-by: hieptl Co-authored-by: Chloe Co-authored-by: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com> --- .gitignore | 2 + frontend/.gitignore | 1 + .../chat/expandable-message.test.tsx | 2 +- .../account-settings-context-menu.test.tsx | 214 ---- .../org/confirm-remove-member-modal.test.tsx | 91 ++ .../org/confirm-update-role-modal.test.tsx | 102 ++ .../invite-organization-member-modal.test.tsx | 141 +++ .../features/org/org-selector.test.tsx | 203 ++++ .../settings/settings-navigation.test.tsx | 109 ++ .../features/user/user-context-menu.test.tsx | 633 ++++++++++ .../components/landing-translations.test.tsx | 219 ---- .../__tests__/components/ui/dropdown.test.tsx | 429 +++++++ .../components/user-actions.test.tsx | 336 ++++-- .../__tests__/components/user-avatar.test.tsx | 38 +- frontend/__tests__/helpers/mock-config.ts | 32 - .../use-invite-members-batch.test.tsx | 45 + .../hooks/mutation/use-remove-member.test.tsx | 45 + .../hooks/query/use-organizations.test.tsx | 174 +++ .../hooks/use-org-type-and-access.test.tsx | 134 +++ .../__tests__/hooks/use-permission.test.tsx | 98 ++ .../hooks/use-settings-nav-items.test.tsx | 223 +++- frontend/__tests__/i18n/translations.test.tsx | 79 -- frontend/__tests__/routes/api-keys.test.tsx | 10 + .../__tests__/routes/app-settings.test.tsx | 10 +- frontend/__tests__/routes/billing.test.tsx | 367 ++++++ .../__tests__/routes/git-settings.test.tsx | 10 +- .../__tests__/routes/home-screen.test.tsx | 12 +- .../__tests__/routes/llm-settings.test.tsx | 719 ++++++++++- frontend/__tests__/routes/manage-org.test.tsx | 954 +++++++++++++++ .../manage-organization-members.test.tsx | 1062 +++++++++++++++++ .../__tests__/routes/mcp-settings.test.tsx | 10 + .../routes/secrets-settings.test.tsx | 74 +- .../routes/settings-with-payment.test.tsx | 135 ++- frontend/__tests__/routes/settings.test.tsx | 449 ++++++- .../selected-organization-store.test.ts | 51 + .../utils/billing-visibility.test.ts | 50 + .../__tests__/utils/input-validation.test.ts | 172 +++ .../__tests__/utils/permission-checks.test.ts | 79 ++ .../__tests__/utils/permission-guard.test.ts | 175 +++ frontend/playwright.config.ts | 2 +- .../organization-service.api.ts | 159 +++ .../account-settings-context-menu.tsx | 116 -- .../features/org/change-org-name-modal.tsx | 45 + .../org/confirm-remove-member-modal.tsx | 42 + .../org/confirm-update-role-modal.tsx | 47 + .../org/delete-org-confirmation-modal.tsx | 52 + .../org/invite-organization-member-modal.tsx | 68 ++ .../components/features/org/org-selector.tsx | 61 + .../org/organization-member-list-item.tsx | 85 ++ .../organization-member-role-context-menu.tsx | 123 ++ .../features/payment/payment-form.tsx | 5 +- .../features/payment/setup-payment-modal.tsx | 2 +- .../features/settings/brand-button.tsx | 2 +- .../settings/settings-dropdown-input.tsx | 4 +- .../features/settings/settings-navigation.tsx | 24 +- .../components/features/sidebar/sidebar.tsx | 3 - .../features/sidebar/user-actions.tsx | 102 +- .../features/sidebar/user-avatar.tsx | 4 +- .../features/user/user-context-menu.tsx | 168 +++ .../components/shared/inputs/badge-input.tsx | 7 +- .../src/components/shared/loading-spinner.tsx | 20 +- .../modals/confirmation-modals/base-modal.tsx | 2 +- .../shared/modals/modal-backdrop.tsx | 14 +- .../shared/modals/modal-button-group.tsx | 70 ++ .../components/shared/modals/org-modal.tsx | 90 ++ .../src/components/v1/chat/event-message.tsx | 1 - frontend/src/constants/settings-nav.tsx | 11 + .../src/context/use-selected-organization.ts | 28 + .../hooks/mutation/use-delete-organization.ts | 36 + .../mutation/use-invite-members-batch.ts | 38 + .../src/hooks/mutation/use-remove-member.ts | 38 + .../hooks/mutation/use-switch-organization.ts | 36 + .../use-unified-start-conversation.ts | 14 + .../hooks/mutation/use-update-member-role.ts | 46 + .../hooks/mutation/use-update-organization.ts | 28 + .../hooks/organizations/use-permissions.ts | 17 + frontend/src/hooks/query/use-me.ts | 18 + .../query/use-organization-members-count.ts | 24 + .../hooks/query/use-organization-members.ts | 30 + .../query/use-organization-payment-info.tsx | 16 + frontend/src/hooks/query/use-organization.ts | 14 + frontend/src/hooks/query/use-organizations.ts | 32 + .../src/hooks/use-auto-select-organization.ts | 33 + frontend/src/hooks/use-org-type-and-access.ts | 22 + frontend/src/hooks/use-settings-nav-items.ts | 56 +- .../src/hooks/use-should-hide-org-selector.ts | 16 + frontend/src/i18n/declaration.ts | 57 + frontend/src/i18n/translation.json | 912 ++++++++++++++ frontend/src/icons/admin.svg | 3 + frontend/src/icons/loading-outer.svg | 2 +- frontend/src/mocks/handlers.ts | 2 + frontend/src/mocks/org-handlers.ts | 556 +++++++++ frontend/src/mocks/settings-handlers.ts | 35 +- frontend/src/routes.ts | 2 + frontend/src/routes/api-keys.tsx | 3 + frontend/src/routes/app-settings.tsx | 5 + frontend/src/routes/billing.tsx | 46 +- frontend/src/routes/git-settings.tsx | 3 + frontend/src/routes/llm-settings.tsx | 50 +- frontend/src/routes/manage-org.tsx | 219 ++++ .../routes/manage-organization-members.tsx | 269 +++++ frontend/src/routes/mcp-settings.tsx | 3 + frontend/src/routes/root-layout.tsx | 9 +- frontend/src/routes/secrets-settings.tsx | 3 + frontend/src/routes/settings.tsx | 158 +-- .../src/stores/selected-organization-store.ts | 30 + frontend/src/tailwind.css | 8 + frontend/src/types/org.ts | 58 + frontend/src/ui/context-menu-icon-text.tsx | 30 + frontend/src/ui/credits-chip.tsx | 30 + frontend/src/ui/dropdown/clear-button.tsx | 19 + frontend/src/ui/dropdown/dropdown-input.tsx | 27 + frontend/src/ui/dropdown/dropdown-menu.tsx | 63 + frontend/src/ui/dropdown/dropdown.tsx | 128 ++ frontend/src/ui/dropdown/loading-spinner.tsx | 8 + frontend/src/ui/dropdown/toggle-button.tsx | 31 + frontend/src/ui/dropdown/types.ts | 4 + frontend/src/ui/interactive-chip.tsx | 33 + frontend/src/ui/pagination.tsx | 129 ++ .../src/utils/get-component-prop-types.ts | 2 + frontend/src/utils/input-validation.ts | 35 + frontend/src/utils/org/billing-visibility.ts | 19 + frontend/src/utils/org/permission-checks.ts | 50 + frontend/src/utils/org/permission-guard.ts | 84 ++ frontend/src/utils/org/permissions.ts | 69 ++ frontend/src/utils/query-client-getters.ts | 5 + frontend/src/utils/settings-utils.ts | 57 + frontend/tailwind.config.js | 20 + frontend/test-utils.tsx | 63 +- frontend/tests/avatar-menu.spec.ts | 36 +- frontend/vitest.setup.ts | 2 + 131 files changed, 11876 insertions(+), 1061 deletions(-) delete mode 100644 frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx create mode 100644 frontend/__tests__/components/features/org/confirm-remove-member-modal.test.tsx create mode 100644 frontend/__tests__/components/features/org/confirm-update-role-modal.test.tsx create mode 100644 frontend/__tests__/components/features/org/invite-organization-member-modal.test.tsx create mode 100644 frontend/__tests__/components/features/org/org-selector.test.tsx create mode 100644 frontend/__tests__/components/features/settings/settings-navigation.test.tsx create mode 100644 frontend/__tests__/components/features/user/user-context-menu.test.tsx delete mode 100644 frontend/__tests__/components/landing-translations.test.tsx create mode 100644 frontend/__tests__/components/ui/dropdown.test.tsx delete mode 100644 frontend/__tests__/helpers/mock-config.ts create mode 100644 frontend/__tests__/hooks/mutation/use-invite-members-batch.test.tsx create mode 100644 frontend/__tests__/hooks/mutation/use-remove-member.test.tsx create mode 100644 frontend/__tests__/hooks/query/use-organizations.test.tsx create mode 100644 frontend/__tests__/hooks/use-org-type-and-access.test.tsx create mode 100644 frontend/__tests__/hooks/use-permission.test.tsx delete mode 100644 frontend/__tests__/i18n/translations.test.tsx create mode 100644 frontend/__tests__/routes/api-keys.test.tsx create mode 100644 frontend/__tests__/routes/billing.test.tsx create mode 100644 frontend/__tests__/routes/manage-org.test.tsx create mode 100644 frontend/__tests__/routes/manage-organization-members.test.tsx create mode 100644 frontend/__tests__/routes/mcp-settings.test.tsx create mode 100644 frontend/__tests__/stores/selected-organization-store.test.ts create mode 100644 frontend/__tests__/utils/billing-visibility.test.ts create mode 100644 frontend/__tests__/utils/input-validation.test.ts create mode 100644 frontend/__tests__/utils/permission-checks.test.ts create mode 100644 frontend/__tests__/utils/permission-guard.test.ts create mode 100644 frontend/src/api/organization-service/organization-service.api.ts delete mode 100644 frontend/src/components/features/context-menu/account-settings-context-menu.tsx create mode 100644 frontend/src/components/features/org/change-org-name-modal.tsx create mode 100644 frontend/src/components/features/org/confirm-remove-member-modal.tsx create mode 100644 frontend/src/components/features/org/confirm-update-role-modal.tsx create mode 100644 frontend/src/components/features/org/delete-org-confirmation-modal.tsx create mode 100644 frontend/src/components/features/org/invite-organization-member-modal.tsx create mode 100644 frontend/src/components/features/org/org-selector.tsx create mode 100644 frontend/src/components/features/org/organization-member-list-item.tsx create mode 100644 frontend/src/components/features/org/organization-member-role-context-menu.tsx create mode 100644 frontend/src/components/features/user/user-context-menu.tsx create mode 100644 frontend/src/components/shared/modals/modal-button-group.tsx create mode 100644 frontend/src/components/shared/modals/org-modal.tsx create mode 100644 frontend/src/context/use-selected-organization.ts create mode 100644 frontend/src/hooks/mutation/use-delete-organization.ts create mode 100644 frontend/src/hooks/mutation/use-invite-members-batch.ts create mode 100644 frontend/src/hooks/mutation/use-remove-member.ts create mode 100644 frontend/src/hooks/mutation/use-switch-organization.ts create mode 100644 frontend/src/hooks/mutation/use-update-member-role.ts create mode 100644 frontend/src/hooks/mutation/use-update-organization.ts create mode 100644 frontend/src/hooks/organizations/use-permissions.ts create mode 100644 frontend/src/hooks/query/use-me.ts create mode 100644 frontend/src/hooks/query/use-organization-members-count.ts create mode 100644 frontend/src/hooks/query/use-organization-members.ts create mode 100644 frontend/src/hooks/query/use-organization-payment-info.tsx create mode 100644 frontend/src/hooks/query/use-organization.ts create mode 100644 frontend/src/hooks/query/use-organizations.ts create mode 100644 frontend/src/hooks/use-auto-select-organization.ts create mode 100644 frontend/src/hooks/use-org-type-and-access.ts create mode 100644 frontend/src/hooks/use-should-hide-org-selector.ts create mode 100644 frontend/src/icons/admin.svg create mode 100644 frontend/src/mocks/org-handlers.ts create mode 100644 frontend/src/routes/manage-org.tsx create mode 100644 frontend/src/routes/manage-organization-members.tsx create mode 100644 frontend/src/stores/selected-organization-store.ts create mode 100644 frontend/src/types/org.ts create mode 100644 frontend/src/ui/context-menu-icon-text.tsx create mode 100644 frontend/src/ui/credits-chip.tsx create mode 100644 frontend/src/ui/dropdown/clear-button.tsx create mode 100644 frontend/src/ui/dropdown/dropdown-input.tsx create mode 100644 frontend/src/ui/dropdown/dropdown-menu.tsx create mode 100644 frontend/src/ui/dropdown/dropdown.tsx create mode 100644 frontend/src/ui/dropdown/loading-spinner.tsx create mode 100644 frontend/src/ui/dropdown/toggle-button.tsx create mode 100644 frontend/src/ui/dropdown/types.ts create mode 100644 frontend/src/ui/interactive-chip.tsx create mode 100644 frontend/src/ui/pagination.tsx create mode 100644 frontend/src/utils/get-component-prop-types.ts create mode 100644 frontend/src/utils/input-validation.ts create mode 100644 frontend/src/utils/org/billing-visibility.ts create mode 100644 frontend/src/utils/org/permission-checks.ts create mode 100644 frontend/src/utils/org/permission-guard.ts create mode 100644 frontend/src/utils/org/permissions.ts create mode 100644 frontend/src/utils/query-client-getters.ts diff --git a/.gitignore b/.gitignore index 6fc0934a02..47512f9d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -234,6 +234,8 @@ yarn-error.log* logs +ralph/ + # agent .envrc /workspace diff --git a/frontend/.gitignore b/frontend/.gitignore index 13f00df210..9fa77e5182 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -8,3 +8,4 @@ node_modules/ /blob-report/ /playwright/.cache/ .react-router/ +ralph/ diff --git a/frontend/__tests__/components/chat/expandable-message.test.tsx b/frontend/__tests__/components/chat/expandable-message.test.tsx index d55e926450..96f5f8ee4a 100644 --- a/frontend/__tests__/components/chat/expandable-message.test.tsx +++ b/frontend/__tests__/components/chat/expandable-message.test.tsx @@ -113,7 +113,7 @@ describe("ExpandableMessage", () => { it("should render the out of credits message when the user is out of credits", async () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - We only care about the app_mode and feature_flags fields + // @ts-expect-error - partial mock for testing getConfigSpy.mockResolvedValue({ app_mode: "saas", feature_flags: { diff --git a/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx b/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx deleted file mode 100644 index cc009e894d..0000000000 --- a/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest"; -import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu"; -import { MemoryRouter } from "react-router"; -import { renderWithProviders } from "../../../test-utils"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createMockWebClientConfig } from "../../helpers/mock-config"; - -const mockTrackAddTeamMembersButtonClick = vi.fn(); - -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackAddTeamMembersButtonClick: mockTrackAddTeamMembersButtonClick, - }), -})); - -// Mock posthog feature flag -vi.mock("posthog-js/react", () => ({ - useFeatureFlagEnabled: vi.fn(), -})); - -// Import the mocked module to get access to the mock -import * as posthog from "posthog-js/react"; - -describe("AccountSettingsContextMenu", () => { - const user = userEvent.setup(); - const onClickAccountSettingsMock = vi.fn(); - const onLogoutMock = vi.fn(); - const onCloseMock = vi.fn(); - - let queryClient: QueryClient; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - // Set default feature flag to false - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false); - }); - - // Create a wrapper with MemoryRouter and renderWithProviders - const renderWithRouter = (ui: React.ReactElement) => { - return renderWithProviders({ui}); - }; - - const renderWithSaasConfig = (ui: React.ReactElement, options?: { analyticsConsent?: boolean }) => { - queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "saas" })); - queryClient.setQueryData(["settings"], { user_consents_to_analytics: options?.analyticsConsent ?? true }); - return render( - - {ui} - - ); - }; - - const renderWithOssConfig = (ui: React.ReactElement) => { - queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "oss" })); - return render( - - {ui} - - ); - }; - - afterEach(() => { - onClickAccountSettingsMock.mockClear(); - onLogoutMock.mockClear(); - onCloseMock.mockClear(); - mockTrackAddTeamMembersButtonClick.mockClear(); - vi.mocked(posthog.useFeatureFlagEnabled).mockClear(); - }); - - it("should always render the right options", () => { - renderWithRouter( - , - ); - - expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); - expect(screen.getByText("SIDEBAR$DOCS")).toBeInTheDocument(); - expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument(); - }); - - it("should render Documentation link with correct attributes", () => { - renderWithRouter( - , - ); - - const documentationLink = screen.getByText("SIDEBAR$DOCS").closest("a"); - expect(documentationLink).toHaveAttribute("href", "https://docs.openhands.dev"); - expect(documentationLink).toHaveAttribute("target", "_blank"); - expect(documentationLink).toHaveAttribute("rel", "noopener noreferrer"); - }); - - it("should call onLogout when the logout option is clicked", async () => { - renderWithRouter( - , - ); - - const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); - await user.click(logoutOption); - - expect(onLogoutMock).toHaveBeenCalledOnce(); - }); - - test("logout button is always enabled", async () => { - renderWithRouter( - , - ); - - const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); - await user.click(logoutOption); - - expect(onLogoutMock).toHaveBeenCalledOnce(); - }); - - it("should call onClose when clicking outside of the element", async () => { - renderWithRouter( - , - ); - - const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); - await user.click(accountSettingsButton); - await user.click(document.body); - - expect(onCloseMock).toHaveBeenCalledOnce(); - }); - - it("should show Add Team Members button in SaaS mode when feature flag is enabled", () => { - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true); - renderWithSaasConfig( - , - ); - - expect(screen.getByTestId("add-team-members-button")).toBeInTheDocument(); - expect(screen.getByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).toBeInTheDocument(); - }); - - it("should not show Add Team Members button in SaaS mode when feature flag is disabled", () => { - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false); - renderWithSaasConfig( - , - ); - - expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument(); - expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument(); - }); - - it("should not show Add Team Members button in OSS mode even when feature flag is enabled", () => { - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true); - renderWithOssConfig( - , - ); - - expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument(); - expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument(); - }); - - it("should not show Add Team Members button when analytics consent is disabled", () => { - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true); - renderWithSaasConfig( - , - { analyticsConsent: false }, - ); - - expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument(); - expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument(); - }); - - it("should call tracking function and onClose when Add Team Members button is clicked", async () => { - vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true); - renderWithSaasConfig( - , - ); - - const addTeamMembersButton = screen.getByTestId("add-team-members-button"); - await user.click(addTeamMembersButton); - - expect(mockTrackAddTeamMembersButtonClick).toHaveBeenCalledOnce(); - expect(onCloseMock).toHaveBeenCalledOnce(); - }); -}); diff --git a/frontend/__tests__/components/features/org/confirm-remove-member-modal.test.tsx b/frontend/__tests__/components/features/org/confirm-remove-member-modal.test.tsx new file mode 100644 index 0000000000..922e8a19ab --- /dev/null +++ b/frontend/__tests__/components/features/org/confirm-remove-member-modal.test.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "test-utils"; +import { ConfirmRemoveMemberModal } from "#/components/features/org/confirm-remove-member-modal"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + Trans: ({ + values, + components, + }: { + values: { email: string }; + components: { email: React.ReactElement }; + }) => React.cloneElement(components.email, {}, values.email), +})); + +describe("ConfirmRemoveMemberModal", () => { + it("should display the member email in the confirmation message", () => { + // Arrange + const memberEmail = "test@example.com"; + + // Act + renderWithProviders( + , + ); + + // Assert + expect(screen.getByText(memberEmail)).toBeInTheDocument(); + }); + + it("should call onConfirm when the confirm button is clicked", async () => { + // Arrange + const user = userEvent.setup(); + const onConfirmMock = vi.fn(); + renderWithProviders( + , + ); + + // Act + await user.click(screen.getByTestId("confirm-button")); + + // Assert + expect(onConfirmMock).toHaveBeenCalledOnce(); + }); + + it("should call onCancel when the cancel button is clicked", async () => { + // Arrange + const user = userEvent.setup(); + const onCancelMock = vi.fn(); + renderWithProviders( + , + ); + + // Act + await user.click(screen.getByTestId("cancel-button")); + + // Assert + expect(onCancelMock).toHaveBeenCalledOnce(); + }); + + it("should disable buttons and show loading spinner when isLoading is true", () => { + // Arrange & Act + renderWithProviders( + , + ); + + // Assert + expect(screen.getByTestId("confirm-button")).toBeDisabled(); + expect(screen.getByTestId("cancel-button")).toBeDisabled(); + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/features/org/confirm-update-role-modal.test.tsx b/frontend/__tests__/components/features/org/confirm-update-role-modal.test.tsx new file mode 100644 index 0000000000..d9409565a9 --- /dev/null +++ b/frontend/__tests__/components/features/org/confirm-update-role-modal.test.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "test-utils"; +import { ConfirmUpdateRoleModal } from "#/components/features/org/confirm-update-role-modal"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + Trans: ({ + values, + components, + }: { + values: { email: string; role: string }; + components: { email: React.ReactElement; role: React.ReactElement }; + }) => ( + <> + {React.cloneElement(components.email, {}, values.email)} + {React.cloneElement(components.role, {}, values.role)} + + ), +})); + +describe("ConfirmUpdateRoleModal", () => { + it("should display the member email and new role in the confirmation message", () => { + // Arrange + const memberEmail = "test@example.com"; + const newRole = "admin"; + + // Act + renderWithProviders( + , + ); + + // Assert + expect(screen.getByText(memberEmail)).toBeInTheDocument(); + expect(screen.getByText(newRole)).toBeInTheDocument(); + }); + + it("should call onConfirm when the confirm button is clicked", async () => { + // Arrange + const user = userEvent.setup(); + const onConfirmMock = vi.fn(); + renderWithProviders( + , + ); + + // Act + await user.click(screen.getByTestId("confirm-button")); + + // Assert + expect(onConfirmMock).toHaveBeenCalledOnce(); + }); + + it("should call onCancel when the cancel button is clicked", async () => { + // Arrange + const user = userEvent.setup(); + const onCancelMock = vi.fn(); + renderWithProviders( + , + ); + + // Act + await user.click(screen.getByTestId("cancel-button")); + + // Assert + expect(onCancelMock).toHaveBeenCalledOnce(); + }); + + it("should disable buttons and show loading spinner when isLoading is true", () => { + // Arrange & Act + renderWithProviders( + , + ); + + // Assert + expect(screen.getByTestId("confirm-button")).toBeDisabled(); + expect(screen.getByTestId("cancel-button")).toBeDisabled(); + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/features/org/invite-organization-member-modal.test.tsx b/frontend/__tests__/components/features/org/invite-organization-member-modal.test.tsx new file mode 100644 index 0000000000..42e470420d --- /dev/null +++ b/frontend/__tests__/components/features/org/invite-organization-member-modal.test.tsx @@ -0,0 +1,141 @@ +import { within, screen, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { InviteOrganizationMemberModal } from "#/components/features/org/invite-organization-member-modal"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import * as ToastHandlers from "#/utils/custom-toast-handlers"; + +vi.mock("react-router", () => ({ + useRevalidator: vi.fn(() => ({ revalidate: vi.fn() })), +})); + +const renderInviteOrganizationMemberModal = (config?: { + onClose: () => void; +}) => + render( + , + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + +describe("InviteOrganizationMemberModal", () => { + beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "1" }); + }); + + afterEach(() => { + vi.clearAllMocks(); + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + it("should call onClose the modal when the close button is clicked", async () => { + const onCloseMock = vi.fn(); + renderInviteOrganizationMemberModal({ onClose: onCloseMock }); + + const modal = screen.getByTestId("invite-modal"); + const closeButton = within(modal).getByRole("button", { + name: /close/i, + }); + await userEvent.click(closeButton); + + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + it("should call the batch API to invite a single team member when the form is submitted", async () => { + const inviteMembersBatchSpy = vi.spyOn( + organizationService, + "inviteMembers", + ); + const onCloseMock = vi.fn(); + + renderInviteOrganizationMemberModal({ onClose: onCloseMock }); + + const modal = screen.getByTestId("invite-modal"); + + const badgeInput = within(modal).getByTestId("emails-badge-input"); + await userEvent.type(badgeInput, "someone@acme.org "); + + // Verify badge is displayed + expect(screen.getByText("someone@acme.org")).toBeInTheDocument(); + + const submitButton = within(modal).getByRole("button", { + name: /add/i, + }); + await userEvent.click(submitButton); + + expect(inviteMembersBatchSpy).toHaveBeenCalledExactlyOnceWith({ + orgId: "1", + emails: ["someone@acme.org"], + }); + + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + it("should allow adding multiple emails using badge input and make a batch POST request", async () => { + const inviteMembersBatchSpy = vi.spyOn( + organizationService, + "inviteMembers", + ); + const onCloseMock = vi.fn(); + + renderInviteOrganizationMemberModal({ onClose: onCloseMock }); + + const modal = screen.getByTestId("invite-modal"); + + // Should have badge input instead of regular input + const badgeInput = within(modal).getByTestId("emails-badge-input"); + expect(badgeInput).toBeInTheDocument(); + + // Add first email by typing and pressing space + await userEvent.type(badgeInput, "user1@acme.org "); + + // Add second email by typing and pressing space + await userEvent.type(badgeInput, "user2@acme.org "); + + // Add third email by typing and pressing space + await userEvent.type(badgeInput, "user3@acme.org "); + + // Verify badges are displayed + expect(screen.getByText("user1@acme.org")).toBeInTheDocument(); + expect(screen.getByText("user2@acme.org")).toBeInTheDocument(); + expect(screen.getByText("user3@acme.org")).toBeInTheDocument(); + + const submitButton = within(modal).getByRole("button", { + name: /add/i, + }); + await userEvent.click(submitButton); + + // Should call batch invite API with all emails + expect(inviteMembersBatchSpy).toHaveBeenCalledExactlyOnceWith({ + orgId: "1", + emails: ["user1@acme.org", "user2@acme.org", "user3@acme.org"], + }); + + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + it("should display an error toast when clicking add button with no emails added", async () => { + // Arrange + const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); + const inviteMembersSpy = vi.spyOn(organizationService, "inviteMembers"); + renderInviteOrganizationMemberModal(); + + // Act + const modal = screen.getByTestId("invite-modal"); + const submitButton = within(modal).getByRole("button", { name: /add/i }); + await userEvent.click(submitButton); + + // Assert + expect(displayErrorToastSpy).toHaveBeenCalledWith( + "ORG$NO_EMAILS_ADDED_HINT", + ); + expect(inviteMembersSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/__tests__/components/features/org/org-selector.test.tsx b/frontend/__tests__/components/features/org/org-selector.test.tsx new file mode 100644 index 0000000000..66f7f95233 --- /dev/null +++ b/frontend/__tests__/components/features/org/org-selector.test.tsx @@ -0,0 +1,203 @@ +import { screen, render, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { OrgSelector } from "#/components/features/org/org-selector"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { + MOCK_PERSONAL_ORG, + MOCK_TEAM_ORG_ACME, + createMockOrganization, +} from "#/mocks/org-handlers"; + +vi.mock("react-router", () => ({ + useRevalidator: () => ({ revalidate: vi.fn() }), + useNavigate: () => vi.fn(), + useLocation: () => ({ pathname: "/" }), + useMatch: () => null, +})); + +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => ({ data: true }), +})); + +// Mock useConfig to return SaaS mode (organizations are a SaaS-only feature) +vi.mock("#/hooks/query/use-config", () => ({ + useConfig: () => ({ data: { app_mode: "saas" } }), +})); + +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "ORG$SELECT_ORGANIZATION_PLACEHOLDER": "Please select an organization", + "ORG$PERSONAL_WORKSPACE": "Personal Workspace", + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +const renderOrgSelector = () => + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + +describe("OrgSelector", () => { + it("should not render when user only has a personal workspace", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + + const { container } = renderOrgSelector(); + + await waitFor(() => { + expect(container).toBeEmptyDOMElement(); + }); + }); + + it("should render when user only has a team organization", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + const { container } = renderOrgSelector(); + + await waitFor(() => { + expect(container).not.toBeEmptyDOMElement(); + }); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should show a loading indicator when fetching organizations", () => { + vi.spyOn(organizationService, "getOrganizations").mockImplementation( + () => new Promise(() => {}), // never resolves + ); + + renderOrgSelector(); + + // The dropdown trigger should be disabled while loading + const trigger = screen.getByTestId("dropdown-trigger"); + expect(trigger).toBeDisabled(); + }); + + it("should select the first organization after orgs are loaded", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + + renderOrgSelector(); + + // The combobox input should show the first org name + await waitFor(() => { + const input = screen.getByRole("combobox"); + expect(input).toHaveValue("Personal Workspace"); + }); + }); + + it("should show all options when dropdown is opened", async () => { + const user = userEvent.setup(); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [ + MOCK_PERSONAL_ORG, + MOCK_TEAM_ORG_ACME, + createMockOrganization("3", "Test Organization", 500), + ], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + + renderOrgSelector(); + + // Wait for the selector to be populated with the first organization + await waitFor(() => { + const input = screen.getByRole("combobox"); + expect(input).toHaveValue("Personal Workspace"); + }); + + // Click the trigger to open dropdown + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + // Verify all 3 options are visible + const listbox = await screen.findByRole("listbox"); + const options = within(listbox).getAllByRole("option"); + + expect(options).toHaveLength(3); + expect(options[0]).toHaveTextContent("Personal Workspace"); + expect(options[1]).toHaveTextContent("Acme Corp"); + expect(options[2]).toHaveTextContent("Test Organization"); + }); + + it("should call switchOrganization API when selecting a different organization", async () => { + // Arrange + const user = userEvent.setup(); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + const switchOrgSpy = vi + .spyOn(organizationService, "switchOrganization") + .mockResolvedValue(MOCK_TEAM_ORG_ACME); + + renderOrgSelector(); + + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace"); + }); + + // Act + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + const listbox = await screen.findByRole("listbox"); + const acmeOption = within(listbox).getByText("Acme Corp"); + await user.click(acmeOption); + + // Assert + expect(switchOrgSpy).toHaveBeenCalledWith({ orgId: MOCK_TEAM_ORG_ACME.id }); + }); + + it("should show loading state while switching organizations", async () => { + // Arrange + const user = userEvent.setup(); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + vi.spyOn(organizationService, "switchOrganization").mockImplementation( + () => new Promise(() => {}), // never resolves to keep loading state + ); + + renderOrgSelector(); + + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace"); + }); + + // Act + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + const listbox = await screen.findByRole("listbox"); + const acmeOption = within(listbox).getByText("Acme Corp"); + await user.click(acmeOption); + + // Assert + await waitFor(() => { + expect(screen.getByTestId("dropdown-trigger")).toBeDisabled(); + }); + }); +}); diff --git a/frontend/__tests__/components/features/settings/settings-navigation.test.tsx b/frontend/__tests__/components/features/settings/settings-navigation.test.tsx new file mode 100644 index 0000000000..41c6b25f91 --- /dev/null +++ b/frontend/__tests__/components/features/settings/settings-navigation.test.tsx @@ -0,0 +1,109 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MemoryRouter } from "react-router"; +import { SettingsNavigation } from "#/components/features/settings/settings-navigation"; +import OptionService from "#/api/option-service/option-service.api"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import { SAAS_NAV_ITEMS, SettingsNavItem } from "#/constants/settings-nav"; + +vi.mock("react-router", async () => ({ + ...(await vi.importActual("react-router")), + useRevalidator: () => ({ revalidate: vi.fn() }), +})); + +const mockConfig = () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue({ + app_mode: "saas", + } as Awaited>); +}; + +const ITEMS_WITHOUT_ORG = SAAS_NAV_ITEMS.filter( + (item) => + item.to !== "/settings/org" && item.to !== "/settings/org-members", +); + +const renderSettingsNavigation = ( + items: SettingsNavItem[] = SAAS_NAV_ITEMS, +) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return render( + + + + + , + ); +}; + +describe("SettingsNavigation", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockConfig(); + useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + }); + + describe("renders navigation items passed via props", () => { + it("should render org routes when included in navigation items", async () => { + renderSettingsNavigation(SAAS_NAV_ITEMS); + + await screen.findByTestId("settings-navbar"); + + const orgMembersLink = await screen.findByText("Organization Members"); + const orgLink = await screen.findByText("Organization"); + + expect(orgMembersLink).toBeInTheDocument(); + expect(orgLink).toBeInTheDocument(); + }); + + it("should not render org routes when excluded from navigation items", async () => { + renderSettingsNavigation(ITEMS_WITHOUT_ORG); + + await screen.findByTestId("settings-navbar"); + + const orgMembersLink = screen.queryByText("Organization Members"); + const orgLink = screen.queryByText("Organization"); + + expect(orgMembersLink).not.toBeInTheDocument(); + expect(orgLink).not.toBeInTheDocument(); + }); + + it("should render all non-org SAAS items regardless of which items are passed", async () => { + renderSettingsNavigation(SAAS_NAV_ITEMS); + + await screen.findByTestId("settings-navbar"); + + // Verify non-org items are rendered (using their i18n keys as text since + // react-i18next returns the key when no translation is loaded) + const secretsLink = await screen.findByText("SETTINGS$NAV_SECRETS"); + const apiKeysLink = await screen.findByText("SETTINGS$NAV_API_KEYS"); + + expect(secretsLink).toBeInTheDocument(); + expect(apiKeysLink).toBeInTheDocument(); + }); + + it("should render empty nav when given an empty items list", async () => { + renderSettingsNavigation([]); + + await screen.findByTestId("settings-navbar"); + + // No nav links should be rendered + const orgMembersLink = screen.queryByText("Organization Members"); + const orgLink = screen.queryByText("Organization"); + + expect(orgMembersLink).not.toBeInTheDocument(); + expect(orgLink).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/__tests__/components/features/user/user-context-menu.test.tsx b/frontend/__tests__/components/features/user/user-context-menu.test.tsx new file mode 100644 index 0000000000..f69de4c0d3 --- /dev/null +++ b/frontend/__tests__/components/features/user/user-context-menu.test.tsx @@ -0,0 +1,633 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router"; +import { UserContextMenu } from "#/components/features/user/user-context-menu"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { GetComponentPropTypes } from "#/utils/get-component-prop-types"; +import { + INITIAL_MOCK_ORGS, + MOCK_PERSONAL_ORG, + MOCK_TEAM_ORG_ACME, +} from "#/mocks/org-handlers"; +import AuthService from "#/api/auth-service/auth-service.api"; +import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; +import OptionService from "#/api/option-service/option-service.api"; +import { OrganizationMember } from "#/types/org"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import { createMockWebClientConfig } from "#/mocks/settings-handlers"; + +type UserContextMenuProps = GetComponentPropTypes; + +function UserContextMenuWithRootOutlet({ + type, + onClose, + onOpenInviteModal, +}: UserContextMenuProps) { + return ( +
+
+ +
+ ); +} + +const renderUserContextMenu = ({ + type, + onClose, + onOpenInviteModal, +}: UserContextMenuProps) => + render( + , + { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + +const { navigateMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), +})); + +vi.mock("react-router", async (importActual) => ({ + ...(await importActual()), + useNavigate: () => navigateMock, + useRevalidator: () => ({ + revalidate: vi.fn(), + }), +})); + +// Mock useIsAuthed to return authenticated state +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => ({ data: true }), +})); + +const createMockUser = ( + overrides: Partial = {}, +): OrganizationMember => ({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + ...overrides, +}); + +const seedActiveUser = (user: Partial) => { + useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser(user), + ); +}; + +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization", + ORG$PERSONAL_WORKSPACE: "Personal Workspace", + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +describe("UserContextMenu", () => { + beforeEach(() => { + // Ensure clean state at the start of each test + vi.restoreAllMocks(); + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + navigateMock.mockClear(); + // Reset Zustand store to ensure clean state between tests + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + it("should render the default context items for a user", () => { + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + screen.getByTestId("org-selector"); + screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); + + expect( + screen.queryByText("ORG$INVITE_ORG_MEMBERS"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("ORG$ORGANIZATION_MEMBERS"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("COMMON$ORGANIZATION"), + ).not.toBeInTheDocument(); + }); + + it("should render navigation items from SAAS_NAV_ITEMS (except organization-members/org)", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out) + const expectedItems = SAAS_NAV_ITEMS.filter( + (item) => + item.to !== "/settings/org-members" && + item.to !== "/settings/org" && + item.to !== "/settings/billing", + ); + + await waitFor(() => { + expectedItems.forEach((item) => { + expect(screen.getByText(item.text)).toBeInTheDocument(); + }); + }); + }); + + it("should render navigation items from SAAS_NAV_ITEMS when user role is admin (except organization-members/org)", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + seedActiveUser({ role: "admin" }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out) + const expectedItems = SAAS_NAV_ITEMS.filter( + (item) => + item.to !== "/settings/org-members" && item.to !== "/settings/org", + ); + + await waitFor(() => { + expectedItems.forEach((item) => { + expect(screen.getByText(item.text)).toBeInTheDocument(); + }); + }); + }); + + it("should not display Organization Members menu item for regular users (filtered out)", () => { + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Organization Members is filtered out from nav items for all users + expect(screen.queryByText("Organization Members")).not.toBeInTheDocument(); + }); + + it("should render a documentation link", () => { + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + const docsLink = screen.getByText("SIDEBAR$DOCS").closest("a"); + expect(docsLink).toHaveAttribute("href", "https://docs.openhands.dev"); + expect(docsLink).toHaveAttribute("target", "_blank"); + }); + + describe("OSS mode", () => { + beforeEach(() => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "oss", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + }); + + it("should render OSS_NAV_ITEMS when in OSS mode", async () => { + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for the config to load and OSS nav items to appear + await waitFor(() => { + OSS_NAV_ITEMS.forEach((item) => { + expect(screen.getByText(item.text)).toBeInTheDocument(); + }); + }); + + // Verify SAAS-only items are NOT rendered (e.g., Billing) + expect( + screen.queryByText("SETTINGS$NAV_BILLING"), + ).not.toBeInTheDocument(); + }); + + it("should not display Organization Members menu item in OSS mode", async () => { + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for the config to load + await waitFor(() => { + expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument(); + }); + + // Verify Organization Members is NOT rendered in OSS mode + expect( + screen.queryByText("Organization Members"), + ).not.toBeInTheDocument(); + }); + }); + + describe("HIDE_LLM_SETTINGS feature flag", () => { + it("should hide LLM settings link when HIDE_LLM_SETTINGS is true", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: true, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + await waitFor(() => { + // Other nav items should still be visible + expect(screen.getByText("SETTINGS$NAV_USER")).toBeInTheDocument(); + // LLM settings (to: "/settings") should NOT be visible + expect( + screen.queryByText("COMMON$LANGUAGE_MODEL_LLM"), + ).not.toBeInTheDocument(); + }); + }); + + it("should show LLM settings link when HIDE_LLM_SETTINGS is false", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + await waitFor(() => { + expect( + screen.getByText("COMMON$LANGUAGE_MODEL_LLM"), + ).toBeInTheDocument(); + }); + }); + }); + + it("should render additional context items when user is an admin", () => { + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + screen.getByTestId("org-selector"); + screen.getByText("ORG$INVITE_ORG_MEMBERS"); + screen.getByText("ORG$ORGANIZATION_MEMBERS"); + screen.getByText("COMMON$ORGANIZATION"); + }); + + it("should render additional context items when user is an owner", () => { + renderUserContextMenu({ type: "owner", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + screen.getByTestId("org-selector"); + screen.getByText("ORG$INVITE_ORG_MEMBERS"); + screen.getByText("ORG$ORGANIZATION_MEMBERS"); + screen.getByText("COMMON$ORGANIZATION"); + }); + + it("should call the logout handler when Logout is clicked", async () => { + const logoutSpy = vi.spyOn(AuthService, "logout"); + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); + await userEvent.click(logoutButton); + + expect(logoutSpy).toHaveBeenCalledOnce(); + }); + + it("should have correct navigation links for nav items", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, // Enable billing so billing link is shown + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + seedActiveUser({ role: "admin" }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for config to load and test a few representative nav items have the correct href + await waitFor(() => { + const userLink = screen.getByText("SETTINGS$NAV_USER").closest("a"); + expect(userLink).toHaveAttribute("href", "/settings/user"); + }); + + await waitFor(() => { + const billingLink = screen.getByText("SETTINGS$NAV_BILLING").closest("a"); + expect(billingLink).toHaveAttribute("href", "/settings/billing"); + }); + + await waitFor(() => { + const integrationsLink = screen + .getByText("SETTINGS$NAV_INTEGRATIONS") + .closest("a"); + expect(integrationsLink).toHaveAttribute( + "href", + "/settings/integrations", + ); + }); + }); + + it("should navigate to /settings/org-members when Manage Organization Members is clicked", async () => { + // Mock a team org so org management buttons are visible (not personal org) + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for orgs to load so org management buttons are visible + const manageOrganizationMembersButton = await screen.findByText( + "ORG$ORGANIZATION_MEMBERS", + ); + await userEvent.click(manageOrganizationMembersButton); + + expect(navigateMock).toHaveBeenCalledExactlyOnceWith( + "/settings/org-members", + ); + }); + + it("should navigate to /settings/org when Manage Account is clicked", async () => { + // Mock a team org so org management buttons are visible (not personal org) + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for orgs to load so org management buttons are visible + const manageAccountButton = await screen.findByText( + "COMMON$ORGANIZATION", + ); + await userEvent.click(manageAccountButton); + + expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/settings/org"); + }); + + it("should call the onClose handler when clicking outside the context menu", async () => { + const onCloseMock = vi.fn(); + renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn }); + + const contextMenu = screen.getByTestId("user-context-menu"); + await userEvent.click(contextMenu); + + expect(onCloseMock).not.toHaveBeenCalled(); + + // Simulate clicking outside the context menu + await userEvent.click(document.body); + + expect(onCloseMock).toHaveBeenCalled(); + }); + + it("should call the onClose handler after each action", async () => { + // Mock a team org so org management buttons are visible + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + const onCloseMock = vi.fn(); + renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn }); + + const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); + await userEvent.click(logoutButton); + expect(onCloseMock).toHaveBeenCalledTimes(1); + + // Wait for orgs to load so org management buttons are visible + const manageOrganizationMembersButton = await screen.findByText( + "ORG$ORGANIZATION_MEMBERS", + ); + await userEvent.click(manageOrganizationMembersButton); + expect(onCloseMock).toHaveBeenCalledTimes(2); + + const manageAccountButton = screen.getByText("COMMON$ORGANIZATION"); + await userEvent.click(manageAccountButton); + expect(onCloseMock).toHaveBeenCalledTimes(3); + }); + + describe("Personal org vs team org visibility", () => { + it("should not show Organization and Organization Members settings items when personal org is selected", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + // Pre-select the personal org in the Zustand store + useSelectedOrganizationStore.setState({ organizationId: "1" }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for org selector to load and org management buttons to disappear + // (they disappear when personal org is selected) + await waitFor(() => { + expect( + screen.queryByText("ORG$ORGANIZATION_MEMBERS"), + ).not.toBeInTheDocument(); + }); + + expect( + screen.queryByText("COMMON$ORGANIZATION"), + ).not.toBeInTheDocument(); + }); + + it("should not show Billing settings item when team org is selected", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for org selector to load and billing to disappear + // (billing disappears when team org is selected) + await waitFor(() => { + expect( + screen.queryByText("SETTINGS$NAV_BILLING"), + ).not.toBeInTheDocument(); + }); + }); + }); + + it("should call onOpenInviteModal and onClose when Invite Organization Member is clicked", async () => { + // Mock a team org so org management buttons are visible (not personal org) + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + const onCloseMock = vi.fn(); + const onOpenInviteModalMock = vi.fn(); + renderUserContextMenu({ + type: "admin", + onClose: onCloseMock, + onOpenInviteModal: onOpenInviteModalMock, + }); + + // Wait for orgs to load so org management buttons are visible + const inviteButton = await screen.findByText("ORG$INVITE_ORG_MEMBERS"); + await userEvent.click(inviteButton); + + expect(onOpenInviteModalMock).toHaveBeenCalledOnce(); + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + test("the user can change orgs", async () => { + // Mock SaaS mode and organizations for this test + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: INITIAL_MOCK_ORGS, + currentOrgId: INITIAL_MOCK_ORGS[0].id, + }); + + const user = userEvent.setup(); + const onCloseMock = vi.fn(); + renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn }); + + // Wait for org selector to appear (it may take a moment for config to load) + const orgSelector = await screen.findByTestId("org-selector"); + expect(orgSelector).toBeInTheDocument(); + + // Wait for organizations to load (indicated by org name appearing in the dropdown) + // INITIAL_MOCK_ORGS[0] is a personal org, so it displays "Personal Workspace" + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace"); + }); + + // Open the dropdown by clicking the trigger + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + // Select a different organization + const orgOption = screen.getByRole("option", { + name: INITIAL_MOCK_ORGS[1].name, + }); + await user.click(orgOption); + + expect(onCloseMock).not.toHaveBeenCalled(); + + // Verify that the dropdown shows the selected organization + expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name); + }); +}); diff --git a/frontend/__tests__/components/landing-translations.test.tsx b/frontend/__tests__/components/landing-translations.test.tsx deleted file mode 100644 index feb6f250f6..0000000000 --- a/frontend/__tests__/components/landing-translations.test.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { test, expect, describe, vi } from "vitest"; -import { useTranslation } from "react-i18next"; -import translations from "../../src/i18n/translation.json"; -import { UserAvatar } from "../../src/components/features/sidebar/user-avatar"; - -vi.mock("@heroui/react", () => ({ - Tooltip: ({ - content, - children, - }: { - content: string; - children: React.ReactNode; - }) => ( -
- {children} -
{content}
-
- ), -})); - -const supportedLanguages = [ - "en", - "ja", - "zh-CN", - "zh-TW", - "ko-KR", - "de", - "no", - "it", - "pt", - "es", - "ar", - "fr", - "tr", -]; - -// Helper function to check if a translation exists for all supported languages -function checkTranslationExists(key: string) { - const missingTranslations: string[] = []; - - const translationEntry = ( - translations as Record> - )[key]; - if (!translationEntry) { - throw new Error( - `Translation key "${key}" does not exist in translation.json`, - ); - } - - for (const lang of supportedLanguages) { - if (!translationEntry[lang]) { - missingTranslations.push(lang); - } - } - - return missingTranslations; -} - -// Helper function to find duplicate translation keys -function findDuplicateKeys(obj: Record) { - const seen = new Set(); - const duplicates = new Set(); - - // Only check top-level keys as these are our translation keys - for (const key in obj) { - if (seen.has(key)) { - duplicates.add(key); - } else { - seen.add(key); - } - } - - return Array.from(duplicates); -} - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translationEntry = ( - translations as Record> - )[key]; - return translationEntry?.ja || key; - }, - }), -})); - -describe("Landing page translations", () => { - test("should render Japanese translations correctly", () => { - // Mock a simple component that uses the translations - const TestComponent = () => { - const { t } = useTranslation(); - return ( -
- {}} /> -
-

{t("LANDING$TITLE")}

- - - - - -
-
- {t("WORKSPACE$TERMINAL_TAB_LABEL")} - {t("WORKSPACE$BROWSER_TAB_LABEL")} - {t("WORKSPACE$JUPYTER_TAB_LABEL")} - {t("WORKSPACE$CODE_EDITOR_TAB_LABEL")} -
-
{t("WORKSPACE$TITLE")}
- -
- {t("TERMINAL$WAITING_FOR_CLIENT")} - {t("STATUS$CONNECTED")} - {t("STATUS$CONNECTED_TO_SERVER")} -
-
- {`5 ${t("TIME$MINUTES_AGO")}`} - {`2 ${t("TIME$HOURS_AGO")}`} - {`3 ${t("TIME$DAYS_AGO")}`} -
-
- ); - }; - - render(); - - // Check main content translations - expect(screen.getByText("開発を始めましょう!")).toBeInTheDocument(); - expect(screen.getByText("VS Codeで開く")).toBeInTheDocument(); - expect( - screen.getByText("テストカバレッジを向上させる"), - ).toBeInTheDocument(); - expect(screen.getByText("Dependabot PRを自動マージ")).toBeInTheDocument(); - expect(screen.getByText("READMEを改善")).toBeInTheDocument(); - expect(screen.getByText("依存関係を整理")).toBeInTheDocument(); - - // Check tab labels - const tabs = screen.getByTestId("tabs"); - expect(tabs).toHaveTextContent("ターミナル"); - expect(tabs).toHaveTextContent("ブラウザ"); - expect(tabs).toHaveTextContent("Jupyter"); - expect(tabs).toHaveTextContent("コードエディタ"); - - // Check workspace label and new project button - expect(screen.getByTestId("workspace-label")).toHaveTextContent( - "ワークスペース", - ); - expect(screen.getByTestId("new-project")).toHaveTextContent( - "新規プロジェクト", - ); - - // Check status messages - const status = screen.getByTestId("status"); - expect(status).toHaveTextContent("クライアントの準備を待機中"); - expect(status).toHaveTextContent("接続済み"); - expect(status).toHaveTextContent("サーバーに接続済み"); - - // Check time-related translations - const time = screen.getByTestId("time"); - expect(time).toHaveTextContent("5 分前"); - expect(time).toHaveTextContent("2 時間前"); - expect(time).toHaveTextContent("3 日前"); - }); - - test("all translation keys should have translations for all supported languages", () => { - // Test all translation keys used in the component - const translationKeys = [ - "LANDING$TITLE", - "VSCODE$OPEN", - "SUGGESTIONS$INCREASE_TEST_COVERAGE", - "SUGGESTIONS$AUTO_MERGE_PRS", - "SUGGESTIONS$FIX_README", - "SUGGESTIONS$CLEAN_DEPENDENCIES", - "WORKSPACE$TERMINAL_TAB_LABEL", - "WORKSPACE$BROWSER_TAB_LABEL", - "WORKSPACE$JUPYTER_TAB_LABEL", - "WORKSPACE$CODE_EDITOR_TAB_LABEL", - "WORKSPACE$TITLE", - "PROJECT$NEW_PROJECT", - "TERMINAL$WAITING_FOR_CLIENT", - "STATUS$CONNECTED", - "STATUS$CONNECTED_TO_SERVER", - "TIME$MINUTES_AGO", - "TIME$HOURS_AGO", - "TIME$DAYS_AGO", - ]; - - // Check all keys and collect missing translations - const missingTranslationsMap = new Map(); - translationKeys.forEach((key) => { - const missing = checkTranslationExists(key); - if (missing.length > 0) { - missingTranslationsMap.set(key, missing); - } - }); - - // If any translations are missing, throw an error with all missing translations - if (missingTranslationsMap.size > 0) { - const errorMessage = Array.from(missingTranslationsMap.entries()) - .map( - ([key, langs]) => - `\n- "${key}" is missing translations for: ${langs.join(", ")}`, - ) - .join(""); - throw new Error(`Missing translations:${errorMessage}`); - } - }); - - test("translation file should not have duplicate keys", () => { - const duplicates = findDuplicateKeys(translations); - - if (duplicates.length > 0) { - throw new Error( - `Found duplicate translation keys: ${duplicates.join(", ")}`, - ); - } - }); -}); diff --git a/frontend/__tests__/components/ui/dropdown.test.tsx b/frontend/__tests__/components/ui/dropdown.test.tsx new file mode 100644 index 0000000000..55a1fa767a --- /dev/null +++ b/frontend/__tests__/components/ui/dropdown.test.tsx @@ -0,0 +1,429 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { Dropdown } from "#/ui/dropdown/dropdown"; + +const mockOptions = [ + { value: "1", label: "Option 1" }, + { value: "2", label: "Option 2" }, + { value: "3", label: "Option 3" }, +]; + +describe("Dropdown", () => { + describe("Trigger", () => { + it("should render a custom trigger button", () => { + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + + expect(trigger).toBeInTheDocument(); + }); + + it("should open dropdown on trigger click", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByText("Option 1")).not.toBeInTheDocument(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const listbox = screen.getByRole("listbox"); + expect(listbox).toBeInTheDocument(); + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + expect(screen.getByText("Option 3")).toBeInTheDocument(); + }); + }); + + describe("Type-ahead / Search", () => { + it("should filter options based on input text", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const input = screen.getByRole("combobox"); + await user.type(input, "Option 1"); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Option 3")).not.toBeInTheDocument(); + }); + + it("should be case-insensitive by default", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const input = screen.getByRole("combobox"); + await user.type(input, "option 1"); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); + }); + + it("should show all options when search is cleared", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const input = screen.getByRole("combobox"); + await user.type(input, "Option 1"); + await user.clear(input); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + expect(screen.getByText("Option 3")).toBeInTheDocument(); + }); + }); + + describe("Empty state", () => { + it("should display empty state when no options provided", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + expect(screen.getByText("No options")).toBeInTheDocument(); + }); + + it("should render custom empty state message", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + expect(screen.getByText("Nothing found")).toBeInTheDocument(); + }); + }); + + describe("Single selection", () => { + it("should select an option on click", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const option = screen.getByText("Option 1"); + await user.click(option); + + expect(screen.getByRole("combobox")).toHaveValue("Option 1"); + }); + + it("should close dropdown after selection", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByText("Option 1")); + + expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); + }); + + it("should display selected option in input", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByText("Option 1")); + + expect(screen.getByRole("combobox")).toHaveValue("Option 1"); + }); + + it("should highlight currently selected option in list", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + await user.click(trigger); + + const selectedOption = screen.getByRole("option", { name: "Option 1" }); + expect(selectedOption).toHaveAttribute("aria-selected", "true"); + }); + + it("should preserve selected value in input and show all options when reopening dropdown", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + // Reopen the dropdown + await user.click(trigger); + + const input = screen.getByRole("combobox"); + expect(input).toHaveValue("Option 1"); + expect( + screen.getByRole("option", { name: "Option 1" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Option 2" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Option 3" }), + ).toBeInTheDocument(); + }); + }); + + describe("Clear button", () => { + it("should not render clear button by default", () => { + render(); + + expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument(); + }); + + it("should render clear button when clearable prop is true and has value", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + expect(screen.getByTestId("dropdown-clear")).toBeInTheDocument(); + }); + + it("should clear selection and search input when clear button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + const clearButton = screen.getByTestId("dropdown-clear"); + await user.click(clearButton); + + expect(screen.getByRole("combobox")).toHaveValue(""); + }); + + it("should not render clear button when there is no selection", () => { + render(); + + expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument(); + }); + + it("should show placeholder after clearing selection", async () => { + const user = userEvent.setup(); + render( + , + ); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + const clearButton = screen.getByTestId("dropdown-clear"); + await user.click(clearButton); + + const input = screen.getByRole("combobox"); + expect(input).toHaveValue(""); + }); + }); + + describe("Loading state", () => { + it("should not display loading indicator by default", () => { + render(); + + expect(screen.queryByTestId("dropdown-loading")).not.toBeInTheDocument(); + }); + + it("should display loading indicator when loading prop is true", () => { + render(); + + expect(screen.getByTestId("dropdown-loading")).toBeInTheDocument(); + }); + + it("should disable interaction while loading", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + }); + + describe("Disabled state", () => { + it("should not open dropdown when disabled", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + + it("should have disabled attribute on trigger", () => { + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + expect(trigger).toBeDisabled(); + }); + }); + + describe("Placeholder", () => { + it("should display placeholder text when no value selected", () => { + render(); + + const input = screen.getByRole("combobox"); + expect(input).toHaveAttribute("placeholder", "Select an option"); + }); + }); + + describe("Default value", () => { + it("should display defaultValue in input on mount", () => { + render(); + + const input = screen.getByRole("combobox"); + expect(input).toHaveValue("Option 1"); + }); + + it("should show all options when opened with defaultValue", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + expect( + screen.getByRole("option", { name: "Option 1" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Option 2" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Option 3" }), + ).toBeInTheDocument(); + }); + + it("should restore input value when closed with Escape", async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const input = screen.getByRole("combobox"); + await user.type(input, "test"); + await user.keyboard("{Escape}"); + + expect(input).toHaveValue("Option 1"); + }); + }); + + describe("onChange", () => { + it("should call onChange with selected item when option is clicked", async () => { + const user = userEvent.setup(); + const onChangeMock = vi.fn(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + await user.click(screen.getByRole("option", { name: "Option 1" })); + + expect(onChangeMock).toHaveBeenCalledWith(mockOptions[0]); + }); + + it("should call onChange with null when selection is cleared", async () => { + const user = userEvent.setup(); + const onChangeMock = vi.fn(); + render( + , + ); + + const clearButton = screen.getByTestId("dropdown-clear"); + await user.click(clearButton); + + expect(onChangeMock).toHaveBeenCalledWith(null); + }); + }); + + describe("Controlled mode", () => { + it.todo("should reflect external value changes"); + it.todo("should call onChange when selection changes"); + it.todo("should not update internal state when controlled"); + }); + + describe("Uncontrolled mode", () => { + it.todo("should manage selection state internally"); + it.todo("should call onChange when selection changes"); + it.todo("should support defaultValue prop"); + }); + + describe("testId prop", () => { + it("should apply custom testId to the root container", () => { + render(); + + expect(screen.getByTestId("org-dropdown")).toBeInTheDocument(); + }); + }); + + describe("Cursor position preservation", () => { + it("should keep menu open when clicking the input while dropdown is open", async () => { + // Without a stateReducer, Downshift's default InputClick behavior + // toggles the menu (closes it if already open). The stateReducer + // should override this to keep the menu open so users can click + // to reposition their cursor without losing the dropdown. + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + // Menu should be open + expect(screen.getByText("Option 1")).toBeInTheDocument(); + + // Click the input itself (simulates clicking to reposition cursor) + const input = screen.getByRole("combobox"); + await user.click(input); + + // Menu should still be open — not toggled closed + expect(screen.getByText("Option 1")).toBeInTheDocument(); + }); + + it("should still filter options correctly after typing with cursor fix", async () => { + // Verifies that the direct onChange handler (which bypasses Downshift's + // default onInputValueChange for cursor preservation) still updates + // the search/filter state correctly. + const user = userEvent.setup(); + render(); + + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + + const input = screen.getByRole("combobox"); + await user.type(input, "Option 1"); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Option 3")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/__tests__/components/user-actions.test.tsx b/frontend/__tests__/components/user-actions.test.tsx index e32931f053..936586168d 100644 --- a/frontend/__tests__/components/user-actions.test.tsx +++ b/frontend/__tests__/components/user-actions.test.tsx @@ -1,11 +1,64 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, test, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, afterEach, beforeEach, test } from "vitest"; import userEvent from "@testing-library/user-event"; -import { UserActions } from "#/components/features/sidebar/user-actions"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; import { MemoryRouter } from "react-router"; import { ReactElement } from "react"; +import { UserActions } from "#/components/features/sidebar/user-actions"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; import { renderWithProviders } from "../../test-utils"; +vi.mock("react-router", async (importActual) => ({ + ...(await importActual()), + useNavigate: () => vi.fn(), + useRevalidator: () => ({ + revalidate: vi.fn(), + }), +})); + +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization", + ORG$PERSONAL_WORKSPACE: "Personal Workspace", + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +const renderUserActions = (props = { hasAvatar: true }) => { + render( + , + { + wrapper: ({ children }) => ( + + + {children} + + + ), + }, + ); +}; + // Create mocks for all the hooks we need const useIsAuthedMock = vi .fn() @@ -38,9 +91,8 @@ describe("UserActions", () => { const onLogoutMock = vi.fn(); // Create a wrapper with MemoryRouter and renderWithProviders - const renderWithRouter = (ui: ReactElement) => { - return renderWithProviders({ui}); - }; + const renderWithRouter = (ui: ReactElement) => + renderWithProviders({ui}); beforeEach(() => { // Reset all mocks to default values before each test @@ -61,29 +113,11 @@ describe("UserActions", () => { }); it("should render", () => { - renderWithRouter(); - + renderUserActions(); expect(screen.getByTestId("user-actions")).toBeInTheDocument(); expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); }); - it("should call onLogout and close the menu when the logout option is clicked", async () => { - renderWithRouter( - , - ); - - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); - - const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); - await user.click(logoutOption); - - expect(onLogoutMock).toHaveBeenCalledOnce(); - }); - it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => { // Set isAuthed to false for this test useIsAuthedMock.mockReturnValue({ data: false, isLoading: false }); @@ -96,29 +130,31 @@ describe("UserActions", () => { providers: [{ id: "github", name: "GitHub" }], }); - renderWithRouter(); + renderUserActions(); const userAvatar = screen.getByTestId("user-avatar"); await user.click(userAvatar); // Context menu should NOT appear because user is not authenticated - expect( - screen.queryByTestId("account-settings-context-menu"), - ).not.toBeInTheDocument(); + expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument(); + }); + + it("should NOT show context menu when user is undefined and avatar is hovered", async () => { + renderUserActions({ hasAvatar: false }); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + + // Context menu should NOT appear because user is undefined + expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument(); }); it("should show context menu even when user has no avatar_url", async () => { - renderWithRouter( - , - ); - - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); // Context menu SHOULD appear because user object exists (even with empty avatar_url) - expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); }); it("should NOT be able to access logout when user is not authenticated", async () => { @@ -133,15 +169,13 @@ describe("UserActions", () => { providers: [{ id: "github", name: "GitHub" }], }); - renderWithRouter(); + renderWithRouter(); const userAvatar = screen.getByTestId("user-avatar"); await user.click(userAvatar); // Context menu should NOT appear because user is not authenticated - expect( - screen.queryByTestId("account-settings-context-menu"), - ).not.toBeInTheDocument(); + expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument(); // Logout option should NOT be accessible when user is not authenticated expect( @@ -161,16 +195,12 @@ describe("UserActions", () => { providers: [{ id: "github", name: "GitHub" }], }); - const { unmount } = renderWithRouter( - , - ); + const { unmount } = renderWithRouter(); // Initially no user and not authenticated - menu should not appear - let userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); - expect( - screen.queryByTestId("account-settings-context-menu"), - ).not.toBeInTheDocument(); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument(); // Unmount the first component unmount(); @@ -188,10 +218,7 @@ describe("UserActions", () => { // Render a new component with user prop and authentication renderWithRouter( - , + , ); // Component should render correctly @@ -199,12 +226,10 @@ describe("UserActions", () => { expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); // Menu should now work with user defined and authenticated - userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); + const userActionsEl = screen.getByTestId("user-actions"); + await user.hover(userActionsEl); - expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); }); it("should handle user prop changing from defined to undefined", async () => { @@ -219,18 +244,13 @@ describe("UserActions", () => { }); const { rerender } = renderWithRouter( - , + , ); - // Click to open menu - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); - expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); + // Hover to open menu + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); // Set authentication to false for the rerender useIsAuthedMock.mockReturnValue({ data: false, isLoading: false }); @@ -246,14 +266,12 @@ describe("UserActions", () => { // Remove user prop - menu should disappear because user is no longer authenticated rerender( - + , ); // Context menu should NOT be visible when user becomes unauthenticated - expect( - screen.queryByTestId("account-settings-context-menu"), - ).not.toBeInTheDocument(); + expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument(); // Logout option should not be accessible expect( @@ -272,20 +290,168 @@ describe("UserActions", () => { providers: [{ id: "github", name: "GitHub" }], }); - renderWithRouter( - , - ); - - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); // Context menu should still appear even when loading + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); + }); + + test("context menu should default to user role", async () => { + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + + // Verify logout is present + expect(screen.getByTestId("user-context-menu")).toHaveTextContent( + "ACCOUNT_SETTINGS$LOGOUT", + ); + // Verify nav items are present (e.g., settings nav items) + expect(screen.getByTestId("user-context-menu")).toHaveTextContent( + "SETTINGS$NAV_USER", + ); + // Verify admin-only items are NOT present for user role expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); + screen.queryByText("ORG$MANAGE_ORGANIZATION_MEMBERS"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("ORG$MANAGE_ORGANIZATION"), + ).not.toBeInTheDocument(); + }); + + test("should NOT show Team and Organization nav items when personal workspace is selected", async () => { + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + + // Team and Organization nav links should NOT be visible when no org is selected (personal workspace) + expect(screen.queryByText("Team")).not.toBeInTheDocument(); + expect(screen.queryByText("Organization")).not.toBeInTheDocument(); + }); + + it("should show context menu on hover", async () => { + renderUserActions(); + + const userActions = screen.getByTestId("user-actions"); + const contextMenu = screen.getByTestId("user-context-menu"); + + // Menu is in DOM but hidden via CSS (opacity-0, pointer-events-none) + expect(contextMenu.parentElement).toHaveClass("opacity-0"); + expect(contextMenu.parentElement).toHaveClass("pointer-events-none"); + + // Hover over the user actions area + await user.hover(userActions); + + // Menu should be visible on hover (CSS classes change via group-hover) + expect(contextMenu).toBeVisible(); + }); + + it("should have pointer-events-none on hover bridge pseudo-element to allow menu item clicks", async () => { + renderUserActions(); + + const userActions = screen.getByTestId("user-actions"); + await user.hover(userActions); + + const contextMenu = screen.getByTestId("user-context-menu"); + const hoverBridgeContainer = contextMenu.parentElement; + + // The hover bridge uses a ::before pseudo-element for diagonal mouse movement + // This pseudo-element MUST have pointer-events-none to allow clicks through to menu items + // The class should include "before:pointer-events-none" to prevent the hover bridge from blocking clicks + expect(hoverBridgeContainer?.className).toContain( + "before:pointer-events-none", + ); + }); + + describe("Org selector dropdown state reset when context menu hides", () => { + // These tests verify that the org selector dropdown resets its internal + // state (search text, open/closed) when the context menu hides and + // reappears. Without this, stale state persists because the context + // menu is hidden via CSS (opacity/pointer-events) rather than unmounted. + + beforeEach(() => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + it("should reset org selector search text when context menu hides and reappears", async () => { + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + + // Hover to show context menu + await user.hover(userActions); + + // Wait for orgs to load and auto-select + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue( + MOCK_PERSONAL_ORG.name, + ); + }); + + // Open dropdown and type search text + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + const input = screen.getByRole("combobox"); + await user.clear(input); + await user.type(input, "search text"); + expect(input).toHaveValue("search text"); + + // Unhover to hide context menu, then hover again + await user.unhover(userActions); + await user.hover(userActions); + + // Org selector should be reset — showing selected org name, not search text + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue( + MOCK_PERSONAL_ORG.name, + ); + }); + }); + + it("should reset dropdown to collapsed state when context menu hides and reappears", async () => { + renderUserActions(); + const userActions = screen.getByTestId("user-actions"); + + // Hover to show context menu + await user.hover(userActions); + + // Wait for orgs to load + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue( + MOCK_PERSONAL_ORG.name, + ); + }); + + // Open dropdown and type to change its state + const trigger = screen.getByTestId("dropdown-trigger"); + await user.click(trigger); + const input = screen.getByRole("combobox"); + await user.clear(input); + await user.type(input, "Acme"); + expect(input).toHaveValue("Acme"); + + // Unhover to hide context menu, then hover again + await user.unhover(userActions); + await user.hover(userActions); + + // Wait for fresh component with org data + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveValue( + MOCK_PERSONAL_ORG.name, + ); + }); + + // Dropdown should be collapsed (closed) after reset + expect(screen.getByTestId("dropdown-trigger")).toHaveAttribute( + "aria-expanded", + "false", + ); + // No option elements should be rendered + expect(screen.queryAllByRole("option")).toHaveLength(0); + }); }); }); diff --git a/frontend/__tests__/components/user-avatar.test.tsx b/frontend/__tests__/components/user-avatar.test.tsx index 5e46a6643e..534456761b 100644 --- a/frontend/__tests__/components/user-avatar.test.tsx +++ b/frontend/__tests__/components/user-avatar.test.tsx @@ -1,40 +1,18 @@ import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { UserAvatar } from "#/components/features/sidebar/user-avatar"; describe("UserAvatar", () => { - const onClickMock = vi.fn(); - - afterEach(() => { - onClickMock.mockClear(); - }); - it("(default) should render the placeholder avatar when the user is logged out", () => { - render(); + render(); expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); expect( screen.getByLabelText("USER$AVATAR_PLACEHOLDER"), ).toBeInTheDocument(); }); - it("should call onClick when clicked", async () => { - const user = userEvent.setup(); - render(); - - const userAvatarContainer = screen.getByTestId("user-avatar"); - await user.click(userAvatarContainer); - - expect(onClickMock).toHaveBeenCalledOnce(); - }); - it("should display the user's avatar when available", () => { - render( - , - ); + render(); expect(screen.getByAltText("AVATAR$ALT_TEXT")).toBeInTheDocument(); expect( @@ -43,24 +21,20 @@ describe("UserAvatar", () => { }); it("should display a loading spinner instead of an avatar when isLoading is true", () => { - const { rerender } = render(); + const { rerender } = render(); expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); expect( screen.getByLabelText("USER$AVATAR_PLACEHOLDER"), ).toBeInTheDocument(); - rerender(); + rerender(); expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); expect( screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"), ).not.toBeInTheDocument(); rerender( - , + , ); expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); expect(screen.queryByAltText("AVATAR$ALT_TEXT")).not.toBeInTheDocument(); diff --git a/frontend/__tests__/helpers/mock-config.ts b/frontend/__tests__/helpers/mock-config.ts deleted file mode 100644 index 36141a4773..0000000000 --- a/frontend/__tests__/helpers/mock-config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { WebClientConfig } from "#/api/option-service/option.types"; - -/** - * Creates a mock WebClientConfig with all required fields. - * Use this helper to create test config objects with sensible defaults. - */ -export const createMockWebClientConfig = ( - overrides: Partial = {}, -): WebClientConfig => ({ - app_mode: "oss", - posthog_client_key: "test-posthog-key", - feature_flags: { - enable_billing: false, - hide_llm_settings: false, - enable_jira: false, - enable_jira_dc: false, - enable_linear: false, - hide_users_page: false, - hide_billing_page: false, - hide_integrations_page: false, - ...overrides.feature_flags, - }, - providers_configured: [], - maintenance_start_time: null, - auth_url: null, - recaptcha_site_key: null, - faulty_models: [], - error_message: null, - updated_at: new Date().toISOString(), - github_app_slug: null, - ...overrides, -}); diff --git a/frontend/__tests__/hooks/mutation/use-invite-members-batch.test.tsx b/frontend/__tests__/hooks/mutation/use-invite-members-batch.test.tsx new file mode 100644 index 0000000000..b30d9104e1 --- /dev/null +++ b/frontend/__tests__/hooks/mutation/use-invite-members-batch.test.tsx @@ -0,0 +1,45 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useInviteMembersBatch } from "#/hooks/mutation/use-invite-members-batch"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; + +// Mock the useRevalidator hook from react-router +vi.mock("react-router", () => ({ + useRevalidator: () => ({ + revalidate: vi.fn(), + }), +})); + +describe("useInviteMembersBatch", () => { + beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + it("should throw an error when organizationId is null", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + retry: false, + }, + }, + }); + + const { result } = renderHook(() => useInviteMembersBatch(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + // Attempt to mutate without organizationId + result.current.mutate({ emails: ["test@example.com"] }); + + // Should fail with an error about missing organizationId + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe("Organization ID is required"); + }); +}); diff --git a/frontend/__tests__/hooks/mutation/use-remove-member.test.tsx b/frontend/__tests__/hooks/mutation/use-remove-member.test.tsx new file mode 100644 index 0000000000..3eebd139f5 --- /dev/null +++ b/frontend/__tests__/hooks/mutation/use-remove-member.test.tsx @@ -0,0 +1,45 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useRemoveMember } from "#/hooks/mutation/use-remove-member"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; + +// Mock the useRevalidator hook from react-router +vi.mock("react-router", () => ({ + useRevalidator: () => ({ + revalidate: vi.fn(), + }), +})); + +describe("useRemoveMember", () => { + beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + it("should throw an error when organizationId is null", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + retry: false, + }, + }, + }); + + const { result } = renderHook(() => useRemoveMember(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + // Attempt to mutate without organizationId + result.current.mutate({ userId: "user-123" }); + + // Should fail with an error about missing organizationId + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe("Organization ID is required"); + }); +}); diff --git a/frontend/__tests__/hooks/query/use-organizations.test.tsx b/frontend/__tests__/hooks/query/use-organizations.test.tsx new file mode 100644 index 0000000000..a42a5b3e77 --- /dev/null +++ b/frontend/__tests__/hooks/query/use-organizations.test.tsx @@ -0,0 +1,174 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useOrganizations } from "#/hooks/query/use-organizations"; +import type { Organization } from "#/types/org"; + +vi.mock("#/api/organization-service/organization-service.api", () => ({ + organizationService: { + getOrganizations: vi.fn(), + }, +})); + +// Mock useIsAuthed to return authenticated +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => ({ data: true }), +})); + +// Mock useConfig to return SaaS mode (organizations are a SaaS-only feature) +vi.mock("#/hooks/query/use-config", () => ({ + useConfig: () => ({ data: { app_mode: "saas" } }), +})); + +const mockGetOrganizations = vi.mocked(organizationService.getOrganizations); + +function createMinimalOrg( + id: string, + name: string, + is_personal?: boolean, +): Organization { + return { + id, + name, + is_personal, + contact_name: "", + contact_email: "", + conversation_expiration: 0, + agent: "", + default_max_iterations: 0, + security_analyzer: "", + confirmation_mode: false, + default_llm_model: "", + default_llm_api_key_for_byor: "", + default_llm_base_url: "", + remote_runtime_resource_factor: 0, + enable_default_condenser: false, + billing_margin: 0, + enable_proactive_conversation_starters: false, + sandbox_base_container_image: "", + sandbox_runtime_container_image: "", + org_version: 0, + mcp_config: { tools: [], settings: {} }, + search_api_key: null, + sandbox_api_key: null, + max_budget_per_task: 0, + enable_solvability_analysis: false, + v1_enabled: false, + credits: 0, + }; +} + +describe("useOrganizations", () => { + let queryClient: QueryClient; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + vi.clearAllMocks(); + }); + + it("sorts personal workspace first, then non-personal alphabetically by name", async () => { + // API returns unsorted: Beta, Personal, Acme, All Hands + mockGetOrganizations.mockResolvedValue({ + items: [ + createMinimalOrg("3", "Beta LLC", false), + createMinimalOrg("1", "Personal Workspace", true), + createMinimalOrg("2", "Acme Corp", false), + createMinimalOrg("4", "All Hands AI", false), + ], + currentOrgId: "1", + }); + + const { result } = renderHook(() => useOrganizations(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const { organizations } = result.current.data!; + expect(organizations).toHaveLength(4); + expect(organizations[0].id).toBe("1"); + expect(organizations[0].is_personal).toBe(true); + expect(organizations[0].name).toBe("Personal Workspace"); + expect(organizations[1].name).toBe("Acme Corp"); + expect(organizations[2].name).toBe("All Hands AI"); + expect(organizations[3].name).toBe("Beta LLC"); + }); + + it("treats missing is_personal as false and sorts by name", async () => { + mockGetOrganizations.mockResolvedValue({ + items: [ + createMinimalOrg("1", "Zebra Org"), // no is_personal + createMinimalOrg("2", "Alpha Org", true), // personal first + createMinimalOrg("3", "Mango Org"), // no is_personal + ], + currentOrgId: "2", + }); + + const { result } = renderHook(() => useOrganizations(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const { organizations } = result.current.data!; + expect(organizations[0].id).toBe("2"); + expect(organizations[0].is_personal).toBe(true); + expect(organizations[1].name).toBe("Mango Org"); + expect(organizations[2].name).toBe("Zebra Org"); + }); + + it("handles missing name by treating as empty string for sort", async () => { + const orgWithName = createMinimalOrg("2", "Beta", false); + const orgNoName = { ...createMinimalOrg("1", "Alpha", false) }; + delete (orgNoName as Record).name; + mockGetOrganizations.mockResolvedValue({ + items: [orgWithName, orgNoName] as Organization[], + currentOrgId: "1", + }); + + const { result } = renderHook(() => useOrganizations(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const { organizations } = result.current.data!; + // undefined name is coerced to ""; "" sorts before "Beta" + expect(organizations[0].id).toBe("1"); + expect(organizations[1].id).toBe("2"); + expect(organizations[1].name).toBe("Beta"); + }); + + it("does not mutate the original array from the API", async () => { + const apiOrgs = [ + createMinimalOrg("2", "Acme", false), + createMinimalOrg("1", "Personal", true), + ]; + mockGetOrganizations.mockResolvedValue({ + items: apiOrgs, + currentOrgId: "1", + }); + + const { result } = renderHook(() => useOrganizations(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Hook sorts a copy ([...data]), so API order unchanged + expect(apiOrgs[0].id).toBe("2"); + expect(apiOrgs[1].id).toBe("1"); + // Returned data is sorted + expect(result.current.data!.organizations[0].id).toBe("1"); + expect(result.current.data!.organizations[1].id).toBe("2"); + }); +}); diff --git a/frontend/__tests__/hooks/use-org-type-and-access.test.tsx b/frontend/__tests__/hooks/use-org-type-and-access.test.tsx new file mode 100644 index 0000000000..4d3d4c9ecc --- /dev/null +++ b/frontend/__tests__/hooks/use-org-type-and-access.test.tsx @@ -0,0 +1,134 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access"; + +// Mock the dependencies +vi.mock("#/context/use-selected-organization", () => ({ + useSelectedOrganizationId: vi.fn(), +})); + +vi.mock("#/hooks/query/use-organizations", () => ({ + useOrganizations: vi.fn(), +})); + +// Import mocked modules +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { useOrganizations } from "#/hooks/query/use-organizations"; + +const mockUseSelectedOrganizationId = vi.mocked(useSelectedOrganizationId); +const mockUseOrganizations = vi.mocked(useOrganizations); + +const queryClient = new QueryClient(); +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("useOrgTypeAndAccess", () => { + beforeEach(() => { + queryClient.clear(); + vi.clearAllMocks(); + }); + + it("should return false for all booleans when no organization is selected", async () => { + mockUseSelectedOrganizationId.mockReturnValue({ + organizationId: null, + setOrganizationId: vi.fn(), + }); + mockUseOrganizations.mockReturnValue({ + data: { organizations: [], currentOrgId: null }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper }); + + await waitFor(() => { + expect(result.current.selectedOrg).toBeUndefined(); + expect(result.current.isPersonalOrg).toBe(false); + expect(result.current.isTeamOrg).toBe(false); + expect(result.current.canViewOrgRoutes).toBe(false); + expect(result.current.organizationId).toBeNull(); + }); + }); + + it("should return isPersonalOrg=true and isTeamOrg=false for personal org", async () => { + const personalOrg = { id: "org-1", is_personal: true, name: "Personal" }; + mockUseSelectedOrganizationId.mockReturnValue({ + organizationId: "org-1", + setOrganizationId: vi.fn(), + }); + mockUseOrganizations.mockReturnValue({ + data: { organizations: [personalOrg], currentOrgId: "org-1" }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper }); + + await waitFor(() => { + expect(result.current.selectedOrg).toEqual(personalOrg); + expect(result.current.isPersonalOrg).toBe(true); + expect(result.current.isTeamOrg).toBe(false); + expect(result.current.canViewOrgRoutes).toBe(false); + expect(result.current.organizationId).toBe("org-1"); + }); + }); + + it("should return isPersonalOrg=false and isTeamOrg=true for team org", async () => { + const teamOrg = { id: "org-2", is_personal: false, name: "Team" }; + mockUseSelectedOrganizationId.mockReturnValue({ + organizationId: "org-2", + setOrganizationId: vi.fn(), + }); + mockUseOrganizations.mockReturnValue({ + data: { organizations: [teamOrg], currentOrgId: "org-2" }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper }); + + await waitFor(() => { + expect(result.current.selectedOrg).toEqual(teamOrg); + expect(result.current.isPersonalOrg).toBe(false); + expect(result.current.isTeamOrg).toBe(true); + expect(result.current.canViewOrgRoutes).toBe(true); + expect(result.current.organizationId).toBe("org-2"); + }); + }); + + it("should return canViewOrgRoutes=true only when isTeamOrg AND organizationId is truthy", async () => { + const teamOrg = { id: "org-3", is_personal: false, name: "Team" }; + mockUseSelectedOrganizationId.mockReturnValue({ + organizationId: "org-3", + setOrganizationId: vi.fn(), + }); + mockUseOrganizations.mockReturnValue({ + data: { organizations: [teamOrg], currentOrgId: "org-3" }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper }); + + await waitFor(() => { + expect(result.current.isTeamOrg).toBe(true); + expect(result.current.organizationId).toBe("org-3"); + expect(result.current.canViewOrgRoutes).toBe(true); + }); + }); + + it("should treat undefined is_personal field as team org", async () => { + // Organization without is_personal field (undefined) + const orgWithoutPersonalField = { id: "org-4", name: "Unknown Type" }; + mockUseSelectedOrganizationId.mockReturnValue({ + organizationId: "org-4", + setOrganizationId: vi.fn(), + }); + mockUseOrganizations.mockReturnValue({ + data: { organizations: [orgWithoutPersonalField], currentOrgId: "org-4" }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper }); + + await waitFor(() => { + expect(result.current.selectedOrg).toEqual(orgWithoutPersonalField); + expect(result.current.isPersonalOrg).toBe(false); + expect(result.current.isTeamOrg).toBe(true); + expect(result.current.canViewOrgRoutes).toBe(true); + }); + }); +}); diff --git a/frontend/__tests__/hooks/use-permission.test.tsx b/frontend/__tests__/hooks/use-permission.test.tsx new file mode 100644 index 0000000000..421e7e0475 --- /dev/null +++ b/frontend/__tests__/hooks/use-permission.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { usePermission } from "#/hooks/organizations/use-permissions"; +import { rolePermissions } from "#/utils/org/permissions"; +import { OrganizationUserRole } from "#/types/org"; + +describe("usePermission", () => { + const setup = (role: OrganizationUserRole) => + renderHook(() => usePermission(role)).result.current; + + describe("hasPermission", () => { + it("returns true when the role has the permission", () => { + const { hasPermission } = setup("admin"); + + expect(hasPermission("invite_user_to_organization")).toBe(true); + }); + + it("returns false when the role does not have the permission", () => { + const { hasPermission } = setup("member"); + + expect(hasPermission("invite_user_to_organization")).toBe(false); + }); + }); + + describe("rolePermissions integration", () => { + it("matches the permissions defined for the role", () => { + const { hasPermission } = setup("member"); + + rolePermissions.member.forEach((permission) => { + expect(hasPermission(permission)).toBe(true); + }); + }); + }); + + describe("change_user_role permission behavior", () => { + const run = ( + activeUserRole: OrganizationUserRole, + targetUserId: string, + targetRole: OrganizationUserRole, + activeUserId = "123", + ) => { + const { hasPermission } = renderHook(() => + usePermission(activeUserRole), + ).result.current; + + // users can't change their own roles + if (activeUserId === targetUserId) return false; + + return hasPermission(`change_user_role:${targetRole}`); + }; + + describe("member role", () => { + it("cannot change any roles", () => { + expect(run("member", "u2", "member")).toBe(false); + expect(run("member", "u2", "admin")).toBe(false); + expect(run("member", "u2", "owner")).toBe(false); + }); + }); + + describe("admin role", () => { + it("cannot change owner role", () => { + expect(run("admin", "u2", "owner")).toBe(false); + }); + + it("can change member or admin roles", () => { + expect(run("admin", "u2", "member")).toBe( + rolePermissions.admin.includes("change_user_role:member") + ); + expect(run("admin", "u2", "admin")).toBe( + rolePermissions.admin.includes("change_user_role:admin") + ); + }); + }); + + describe("owner role", () => { + it("can change owner, admin, and member roles", () => { + expect(run("owner", "u2", "admin")).toBe( + rolePermissions.owner.includes("change_user_role:admin"), + ); + + expect(run("owner", "u2", "member")).toBe( + rolePermissions.owner.includes("change_user_role:member"), + ); + + expect(run("owner", "u2", "owner")).toBe( + rolePermissions.owner.includes("change_user_role:owner"), + ); + }); + }); + + describe("self role change", () => { + it("is always disallowed", () => { + expect(run("owner", "u2", "member", "u2")).toBe(false); + expect(run("admin", "u2", "member", "u2")).toBe(false); + }); + }); + }); +}); diff --git a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx index 43205ff9d5..cc5193464b 100644 --- a/frontend/__tests__/hooks/use-settings-nav-items.test.tsx +++ b/frontend/__tests__/hooks/use-settings-nav-items.test.tsx @@ -6,18 +6,54 @@ import OptionService from "#/api/option-service/option-service.api"; import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; import { WebClientFeatureFlags } from "#/api/option-service/option.types"; +// Mock useOrgTypeAndAccess +const mockOrgTypeAndAccess = vi.hoisted(() => ({ + isPersonalOrg: false, + isTeamOrg: false, + organizationId: null as string | null, + selectedOrg: null, + canViewOrgRoutes: false, +})); + +vi.mock("#/hooks/use-org-type-and-access", () => ({ + useOrgTypeAndAccess: () => mockOrgTypeAndAccess, +})); + +// Mock useMe +const mockMe = vi.hoisted(() => ({ + data: null as { role: string } | null | undefined, +})); + +vi.mock("#/hooks/query/use-me", () => ({ + useMe: () => mockMe, +})); + const queryClient = new QueryClient(); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); -const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => { +const mockConfig = ( + appMode: "saas" | "oss", + hideLlmSettings = false, + enableBilling = true, +) => { vi.spyOn(OptionService, "getConfig").mockResolvedValue({ app_mode: appMode, - feature_flags: { hide_llm_settings: hideLlmSettings }, + feature_flags: { + hide_llm_settings: hideLlmSettings, + enable_billing: enableBilling, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + }, } as Awaited>); }; +vi.mock("react-router", () => ({ + useRevalidator: () => ({ revalidate: vi.fn() }), +})); + const mockConfigWithFeatureFlags = ( appMode: "saas" | "oss", featureFlags: Partial, @@ -25,7 +61,7 @@ const mockConfigWithFeatureFlags = ( vi.spyOn(OptionService, "getConfig").mockResolvedValue({ app_mode: appMode, feature_flags: { - enable_billing: false, + enable_billing: true, // Enable billing by default so it's not hidden hide_llm_settings: false, enable_jira: false, enable_jira_dc: false, @@ -41,19 +77,38 @@ const mockConfigWithFeatureFlags = ( describe("useSettingsNavItems", () => { beforeEach(() => { queryClient.clear(); + vi.restoreAllMocks(); + // Reset mock state + mockOrgTypeAndAccess.isPersonalOrg = false; + mockOrgTypeAndAccess.isTeamOrg = false; + mockOrgTypeAndAccess.organizationId = null; + mockOrgTypeAndAccess.selectedOrg = null; + mockOrgTypeAndAccess.canViewOrgRoutes = false; + mockMe.data = null; }); - it("should return SAAS_NAV_ITEMS when app_mode is 'saas'", async () => { + it("should return SAAS_NAV_ITEMS minus billing/org/org-members when userRole is 'member'", async () => { mockConfig("saas"); + mockMe.data = { role: "member" }; + mockOrgTypeAndAccess.organizationId = "org-1"; + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); await waitFor(() => { - expect(result.current).toEqual(SAAS_NAV_ITEMS); + expect(result.current).toEqual( + SAAS_NAV_ITEMS.filter( + (item) => + item.to !== "/settings/billing" && + item.to !== "/settings/org" && + item.to !== "/settings/org-members", + ), + ); }); }); it("should return OSS_NAV_ITEMS when app_mode is 'oss'", async () => { mockConfig("oss"); + mockMe.data = { role: "admin" }; const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); await waitFor(() => { @@ -63,6 +118,8 @@ describe("useSettingsNavItems", () => { it("should filter out '/settings' item when hide_llm_settings feature flag is enabled", async () => { mockConfig("saas", true); + mockMe.data = { role: "admin" }; + mockOrgTypeAndAccess.organizationId = "org-1"; const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); await waitFor(() => { @@ -72,7 +129,163 @@ describe("useSettingsNavItems", () => { }); }); + describe("org-type and role-based filtering", () => { + it("should include org routes by default for team org admin", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isTeamOrg = true; + mockOrgTypeAndAccess.organizationId = "org-123"; + mockMe.data = { role: "admin" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load (check that any SAAS item is present) + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Org routes should be included for team org admin + expect( + result.current.find((item) => item.to === "/settings/org"), + ).toBeDefined(); + expect( + result.current.find((item) => item.to === "/settings/org-members"), + ).toBeDefined(); + }); + + it("should hide org routes when isPersonalOrg is true", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isPersonalOrg = true; + mockOrgTypeAndAccess.organizationId = "org-123"; + mockMe.data = { role: "admin" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load (check that any SAAS item is present) + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Org routes should be filtered out for personal orgs + expect( + result.current.find((item) => item.to === "/settings/org"), + ).toBeUndefined(); + expect( + result.current.find((item) => item.to === "/settings/org-members"), + ).toBeUndefined(); + }); + + it("should hide org routes when user role is member", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isTeamOrg = true; + mockOrgTypeAndAccess.organizationId = "org-123"; + mockMe.data = { role: "member" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Org routes should be hidden for members + expect( + result.current.find((item) => item.to === "/settings/org"), + ).toBeUndefined(); + expect( + result.current.find((item) => item.to === "/settings/org-members"), + ).toBeUndefined(); + }); + + it("should hide org routes when no organization is selected", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isTeamOrg = false; + mockOrgTypeAndAccess.isPersonalOrg = false; + mockOrgTypeAndAccess.organizationId = null; + mockMe.data = { role: "admin" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Org routes should be hidden when no org is selected + expect( + result.current.find((item) => item.to === "/settings/org"), + ).toBeUndefined(); + expect( + result.current.find((item) => item.to === "/settings/org-members"), + ).toBeUndefined(); + }); + + it("should hide billing route when isTeamOrg is true", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isTeamOrg = true; + mockOrgTypeAndAccess.organizationId = "org-123"; + mockMe.data = { role: "admin" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Billing should be hidden for team orgs + expect( + result.current.find((item) => item.to === "/settings/billing"), + ).toBeUndefined(); + }); + + it("should show billing route for personal org", async () => { + mockConfig("saas"); + mockOrgTypeAndAccess.isPersonalOrg = true; + mockOrgTypeAndAccess.isTeamOrg = false; + mockOrgTypeAndAccess.organizationId = "org-123"; + mockMe.data = { role: "admin" }; + + const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); + + // Wait for config to load + await waitFor(() => { + expect(result.current.length).toBeGreaterThan(0); + expect( + result.current.find((item) => item.to === "/settings/user"), + ).toBeDefined(); + }); + + // Billing should be visible for personal orgs + expect( + result.current.find((item) => item.to === "/settings/billing"), + ).toBeDefined(); + }); + }); + describe("hide page feature flags", () => { + beforeEach(() => { + // Set up user as admin with org context so billing is accessible + mockMe.data = { role: "admin" }; + mockOrgTypeAndAccess.isPersonalOrg = true; // Personal org shows billing + mockOrgTypeAndAccess.isTeamOrg = false; + mockOrgTypeAndAccess.organizationId = "org-1"; + }); + it("should filter out '/settings/user' when hide_users_page is true", async () => { mockConfigWithFeatureFlags("saas", { hide_users_page: true }); const { result } = renderHook(() => useSettingsNavItems(), { wrapper }); diff --git a/frontend/__tests__/i18n/translations.test.tsx b/frontend/__tests__/i18n/translations.test.tsx deleted file mode 100644 index 3b8ff9b4a5..0000000000 --- a/frontend/__tests__/i18n/translations.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import i18n from "../../src/i18n"; -import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu"; -import { renderWithProviders } from "../../test-utils"; -import { MemoryRouter } from "react-router"; - -describe("Translations", () => { - it("should render translated text", () => { - i18n.changeLanguage("en"); - renderWithProviders( - - {}} onClose={() => {}} /> - , - ); - expect( - screen.getByTestId("account-settings-context-menu"), - ).toBeInTheDocument(); - }); - - it("should not attempt to load unsupported language codes", async () => { - // Test that the configuration prevents 404 errors by not attempting to load - // unsupported language codes like 'en-US@posix' - const originalLanguage = i18n.language; - - try { - // With nonExplicitSupportedLngs: false, i18next will not attempt to load - // unsupported language codes, preventing 404 errors - - // Test with a language code that includes region but is not in supportedLngs - await i18n.changeLanguage("en-US@posix"); - - // Since "en-US@posix" is not in supportedLngs and nonExplicitSupportedLngs is false, - // i18next should fall back to the fallbackLng ("en") - expect(i18n.language).toBe("en"); - - // Test another unsupported region code - await i18n.changeLanguage("ja-JP"); - - // Even with nonExplicitSupportedLngs: false, i18next still falls back to base language - // if it exists in supportedLngs, but importantly, it won't make a 404 request first - expect(i18n.language).toBe("ja"); - - // Test that supported languages still work - await i18n.changeLanguage("ja"); - expect(i18n.language).toBe("ja"); - - await i18n.changeLanguage("zh-CN"); - expect(i18n.language).toBe("zh-CN"); - - } finally { - // Restore the original language - await i18n.changeLanguage(originalLanguage); - } - }); - - it("should have proper i18n configuration", () => { - // Test that the i18n instance has the expected configuration - expect(i18n.options.supportedLngs).toBeDefined(); - - // nonExplicitSupportedLngs should be false to prevent 404 errors - expect(i18n.options.nonExplicitSupportedLngs).toBe(false); - - // fallbackLng can be a string or array, check if it includes "en" - const fallbackLng = i18n.options.fallbackLng; - if (Array.isArray(fallbackLng)) { - expect(fallbackLng).toContain("en"); - } else { - expect(fallbackLng).toBe("en"); - } - - // Test that supported languages include both base and region-specific codes - const supportedLngs = i18n.options.supportedLngs as string[]; - expect(supportedLngs).toContain("en"); - expect(supportedLngs).toContain("zh-CN"); - expect(supportedLngs).toContain("zh-TW"); - expect(supportedLngs).toContain("ko-KR"); - }); -}); diff --git a/frontend/__tests__/routes/api-keys.test.tsx b/frontend/__tests__/routes/api-keys.test.tsx new file mode 100644 index 0000000000..f243654845 --- /dev/null +++ b/frontend/__tests__/routes/api-keys.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { clientLoader } from "#/routes/api-keys"; + +describe("clientLoader permission checks", () => { + it("should export a clientLoader for route protection", () => { + // This test verifies the clientLoader is exported (for consistency with other routes) + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); +}); diff --git a/frontend/__tests__/routes/app-settings.test.tsx b/frontend/__tests__/routes/app-settings.test.tsx index 038cb94c52..7b42844246 100644 --- a/frontend/__tests__/routes/app-settings.test.tsx +++ b/frontend/__tests__/routes/app-settings.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; -import AppSettingsScreen from "#/routes/app-settings"; +import AppSettingsScreen, { clientLoader } from "#/routes/app-settings"; import SettingsService from "#/api/settings-service/settings-service.api"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; import { AvailableLanguages } from "#/i18n"; @@ -18,6 +18,14 @@ const renderAppSettingsScreen = () => ), }); +describe("clientLoader permission checks", () => { + it("should export a clientLoader for route protection", () => { + // This test verifies the clientLoader is exported (for consistency with other routes) + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); +}); + describe("Content", () => { it("should render the screen", () => { renderAppSettingsScreen(); diff --git a/frontend/__tests__/routes/billing.test.tsx b/frontend/__tests__/routes/billing.test.tsx new file mode 100644 index 0000000000..f4a943c2b9 --- /dev/null +++ b/frontend/__tests__/routes/billing.test.tsx @@ -0,0 +1,367 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { QueryClientProvider } from "@tanstack/react-query"; +import BillingSettingsScreen, { clientLoader } from "#/routes/billing"; +import { PaymentForm } from "#/components/features/payment/payment-form"; +import OptionService from "#/api/option-service/option-service.api"; +import { OrganizationMember } from "#/types/org"; +import * as orgStore from "#/stores/selected-organization-store"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { createMockWebClientConfig } from "#/mocks/settings-handlers"; + +// Mock the i18next hook +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +// Mock useTracking hook +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackCreditsPurchased: vi.fn(), + }), +})); + +// Mock useBalance hook +const mockUseBalance = vi.fn(); +vi.mock("#/hooks/query/use-balance", () => ({ + useBalance: () => mockUseBalance(), +})); + +// Mock useCreateStripeCheckoutSession hook +vi.mock( + "#/hooks/mutation/stripe/use-create-stripe-checkout-session", + () => ({ + useCreateStripeCheckoutSession: () => ({ + mutate: vi.fn(), + isPending: false, + }), + }), +); + +describe("Billing Route", () => { + const { mockQueryClient } = vi.hoisted(() => ({ + mockQueryClient: (() => { + const { QueryClient } = require("@tanstack/react-query"); + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + })(), + })); + + // Mock queryClient to use our test instance + vi.mock("#/query-client-config", () => ({ + queryClient: mockQueryClient, + })); + + const createMockUser = ( + overrides: Partial = {}, + ): OrganizationMember => ({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + ...overrides, + }); + + const seedActiveUser = (user: Partial) => { + orgStore.useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser(user), + ); + }; + + const setupSaasMode = (featureFlags = {}) => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + ...featureFlags, + }, + }), + ); + }; + + beforeEach(() => { + mockQueryClient.clear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("clientLoader cache key", () => { + it("should use the 'web-client-config' query key to read cached config", async () => { + // Arrange: pre-populate the cache under the canonical key + seedActiveUser({ role: "admin" }); + const cachedConfig = { + app_mode: "saas" as const, + posthog_client_key: "test", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + }, + }; + mockQueryClient.setQueryData(["web-client-config"], cachedConfig); + + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + + // Act: invoke the clientLoader directly + const result = await clientLoader(); + + // Assert: the loader should have found the cached config and NOT called getConfig + expect(getConfigSpy).not.toHaveBeenCalled(); + expect(result).toBeNull(); // admin with billing enabled = no redirect + }); + }); + + describe("clientLoader permission checks", () => { + it("should redirect members to /settings/user when accessing billing directly", async () => { + // Arrange + setupSaasMode(); + seedActiveUser({ role: "member" }); + + const RouterStub = createRoutesStub([ + { + Component: BillingSettingsScreen, + loader: clientLoader, + path: "/settings/billing", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should be redirected to user settings + await waitFor(() => { + expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument(); + }); + }); + + it("should allow admins to access billing route", async () => { + // Arrange + setupSaasMode(); + seedActiveUser({ role: "admin" }); + + const RouterStub = createRoutesStub([ + { + Component: BillingSettingsScreen, + loader: clientLoader, + path: "/settings/billing", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should stay on billing page (component renders PaymentForm) + await waitFor(() => { + expect( + screen.queryByTestId("user-settings-screen"), + ).not.toBeInTheDocument(); + }); + }); + + it("should allow owners to access billing route", async () => { + // Arrange + setupSaasMode(); + seedActiveUser({ role: "owner" }); + + const RouterStub = createRoutesStub([ + { + Component: BillingSettingsScreen, + loader: clientLoader, + path: "/settings/billing", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should stay on billing page + await waitFor(() => { + expect( + screen.queryByTestId("user-settings-screen"), + ).not.toBeInTheDocument(); + }); + }); + + it("should redirect when user is undefined (no org selected)", async () => { + // Arrange: no org selected, so getActiveOrganizationUser returns undefined + setupSaasMode(); + // Explicitly clear org store so getActiveOrganizationUser returns undefined + orgStore.useSelectedOrganizationStore.setState({ organizationId: null }); + + const RouterStub = createRoutesStub([ + { + Component: BillingSettingsScreen, + loader: clientLoader, + path: "/settings/billing", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should be redirected to user settings + await waitFor(() => { + expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument(); + }); + }); + + it("should redirect all users when enable_billing is false", async () => { + // Arrange: enable_billing=false means billing is hidden for everyone + setupSaasMode({ enable_billing: false }); + seedActiveUser({ role: "owner" }); // Even owners should be redirected + + const RouterStub = createRoutesStub([ + { + Component: BillingSettingsScreen, + loader: clientLoader, + path: "/settings/billing", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should be redirected to user settings + await waitFor(() => { + expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument(); + }); + }); + }); + + describe("PaymentForm permission behavior", () => { + beforeEach(() => { + mockUseBalance.mockReturnValue({ + data: "150.00", + isLoading: false, + }); + }); + + it("should disable input and button when isDisabled is true, but show balance", async () => { + // Arrange & Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - balance is visible + const balance = screen.getByTestId("user-balance"); + expect(balance).toBeInTheDocument(); + expect(balance).toHaveTextContent("$150.00"); + + // Assert - input is disabled + const topUpInput = screen.getByTestId("top-up-input"); + expect(topUpInput).toBeDisabled(); + + // Assert - button is disabled + const submitButton = screen.getByRole("button"); + expect(submitButton).toBeDisabled(); + }); + + it("should enable input and button when isDisabled is false", async () => { + // Arrange & Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - input is enabled + const topUpInput = screen.getByTestId("top-up-input"); + expect(topUpInput).not.toBeDisabled(); + + // Assert - button starts disabled (no amount entered) but is NOT + // permanently disabled by the isDisabled prop + const submitButton = screen.getByRole("button"); + // The button is disabled because no valid amount is entered, not because of isDisabled + expect(submitButton).toBeDisabled(); + }); + }); +}); diff --git a/frontend/__tests__/routes/git-settings.test.tsx b/frontend/__tests__/routes/git-settings.test.tsx index 0766790579..860bdaf1d0 100644 --- a/frontend/__tests__/routes/git-settings.test.tsx +++ b/frontend/__tests__/routes/git-settings.test.tsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import i18next from "i18next"; import { I18nextProvider } from "react-i18next"; -import GitSettingsScreen from "#/routes/git-settings"; +import GitSettingsScreen, { clientLoader } from "#/routes/git-settings"; import SettingsService from "#/api/settings-service/settings-service.api"; import OptionService from "#/api/option-service/option-service.api"; import AuthService from "#/api/auth-service/auth-service.api"; @@ -13,7 +13,6 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; import { WebClientConfig } from "#/api/option-service/option.types"; import * as ToastHandlers from "#/utils/custom-toast-handlers"; import { SecretsService } from "#/api/secrets-service"; -import { integrationService } from "#/api/integration-service/integration-service.api"; const VALID_OSS_CONFIG: WebClientConfig = { app_mode: "oss", @@ -657,3 +656,10 @@ describe("GitLab Webhook Manager Integration", () => { }); }); }); + +describe("clientLoader permission checks", () => { + it("should export a clientLoader for route protection", () => { + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); +}); diff --git a/frontend/__tests__/routes/home-screen.test.tsx b/frontend/__tests__/routes/home-screen.test.tsx index df672c6343..27d6825820 100644 --- a/frontend/__tests__/routes/home-screen.test.tsx +++ b/frontend/__tests__/routes/home-screen.test.tsx @@ -541,7 +541,7 @@ describe("Settings 404", () => { }); }); -describe("Setup Payment modal", () => { +describe("New user welcome toast", () => { const getConfigSpy = vi.spyOn(OptionService, "getConfig"); const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); @@ -593,7 +593,7 @@ describe("Setup Payment modal", () => { vi.unstubAllGlobals(); }); - it("should only render if SaaS mode and is new user", async () => { + it("should not show the setup payment modal (removed) in SaaS mode for new users", async () => { getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, is_new_user: true, @@ -603,9 +603,9 @@ describe("Setup Payment modal", () => { await screen.findByTestId("root-layout"); - const setupPaymentModal = await screen.findByTestId( - "proceed-to-stripe-button", - ); - expect(setupPaymentModal).toBeInTheDocument(); + // SetupPaymentModal was removed; verify it no longer renders + expect( + screen.queryByTestId("proceed-to-stripe-button"), + ).not.toBeInTheDocument(); }); }); diff --git a/frontend/__tests__/routes/llm-settings.test.tsx b/frontend/__tests__/routes/llm-settings.test.tsx index 82d2085fe8..2dabd4da79 100644 --- a/frontend/__tests__/routes/llm-settings.test.tsx +++ b/frontend/__tests__/routes/llm-settings.test.tsx @@ -11,12 +11,23 @@ import { import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set"; import * as ToastHandlers from "#/utils/custom-toast-handlers"; import OptionService from "#/api/option-service/option-service.api"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import type { OrganizationMember } from "#/types/org"; // Mock react-router hooks const mockUseSearchParams = vi.fn(); -vi.mock("react-router", () => ({ - useSearchParams: () => mockUseSearchParams(), -})); +vi.mock("react-router", async () => { + const actual = + await vi.importActual("react-router"); + return { + ...actual, + useSearchParams: () => mockUseSearchParams(), + useRevalidator: () => ({ + revalidate: vi.fn(), + }), + }; +}); // Mock useIsAuthed hook const mockUseIsAuthed = vi.fn(); @@ -24,14 +35,63 @@ vi.mock("#/hooks/query/use-is-authed", () => ({ useIsAuthed: () => mockUseIsAuthed(), })); -const renderLlmSettingsScreen = () => - render(, { +// Mock useConfig hook +const mockUseConfig = vi.fn(); +vi.mock("#/hooks/query/use-config", () => ({ + useConfig: () => mockUseConfig(), +})); + +const renderLlmSettingsScreen = ( + orgId: string | null = null, + meData?: { + org_id: string; + user_id: string; + email: string; + role: string; + status: string; + llm_api_key: string; + max_iterations: number; + llm_model: string; + llm_api_key_for_byor: string | null; + llm_base_url: string; + }, +) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + // Default to orgId "1" if not provided (for backward compatibility) + const finalOrgId = orgId ?? "1"; + useSelectedOrganizationStore.setState({ organizationId: finalOrgId }); + + // Pre-populate React Query cache with me data + // If meData is provided, use it; otherwise use default owner data + const defaultMeData = { + org_id: finalOrgId, + user_id: "99", + email: "owner@example.com", + role: "owner", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }; + queryClient.setQueryData( + ["organizations", finalOrgId, "me"], + meData || defaultMeData, + ); + + return render(, { wrapper: ({ children }) => ( - - {children} - + {children} ), }); +}; beforeEach(() => { vi.resetAllMocks(); @@ -47,22 +107,58 @@ beforeEach(() => { // Default mock for useIsAuthed - returns authenticated by default mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false }); + + // Default mock for useConfig - returns SaaS mode by default + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, + }); + + // Default mock for organizationService.getMe - returns owner role by default (full access) + const defaultMeData: OrganizationMember = { + org_id: "1", + user_id: "99", + email: "owner@example.com", + role: "owner", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }; + vi.spyOn(organizationService, "getMe").mockResolvedValue(defaultMeData); + + // Reset organization store + useSelectedOrganizationStore.setState({ organizationId: "1" }); }); describe("Content", () => { describe("Basic form", () => { it("should render the basic form by default", async () => { + // Use OSS mode so API key input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + renderLlmSettingsScreen(); await screen.findByTestId("llm-settings-screen"); - const basicFom = screen.getByTestId("llm-settings-form-basic"); - within(basicFom).getByTestId("llm-provider-input"); - within(basicFom).getByTestId("llm-model-input"); - within(basicFom).getByTestId("llm-api-key-input"); - within(basicFom).getByTestId("llm-api-key-help-anchor"); + const basicForm = screen.getByTestId("llm-settings-form-basic"); + within(basicForm).getByTestId("llm-provider-input"); + within(basicForm).getByTestId("llm-model-input"); + within(basicForm).getByTestId("llm-api-key-input"); + within(basicForm).getByTestId("llm-api-key-help-anchor"); }); it("should render the default values if non exist", async () => { + // Use OSS mode so API key input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + renderLlmSettingsScreen(); await screen.findByTestId("llm-settings-screen"); @@ -142,6 +238,12 @@ describe("Content", () => { }); it("should render the advanced form if the switch is toggled", async () => { + // Use OSS mode so agent-input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + renderLlmSettingsScreen(); await screen.findByTestId("llm-settings-screen"); @@ -176,6 +278,12 @@ describe("Content", () => { }); it("should render the default advanced settings", async () => { + // Use OSS mode so agent-input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + renderLlmSettingsScreen(); await screen.findByTestId("llm-settings-screen"); @@ -215,6 +323,12 @@ describe("Content", () => { }); it("should render existing advanced settings correctly", async () => { + // Use OSS mode so agent-input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, @@ -336,10 +450,10 @@ describe("Content", () => { describe("API key visibility in Basic Settings", () => { it("should hide API key input when SaaS mode is enabled and OpenHands provider is selected", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "saas", + // SaaS mode is already the default from beforeEach, but let's be explicit + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -363,10 +477,10 @@ describe("Content", () => { }); it("should show API key input when SaaS mode is enabled and non-OpenHands provider is selected", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "saas", + // SaaS mode is already the default from beforeEach, but let's be explicit + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -394,10 +508,9 @@ describe("Content", () => { }); it("should show API key input when OSS mode is enabled and OpenHands provider is selected", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "oss", + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -421,10 +534,9 @@ describe("Content", () => { }); it("should show API key input when OSS mode is enabled and non-OpenHands provider is selected", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "oss", + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -452,10 +564,10 @@ describe("Content", () => { }); it("should hide API key input when switching from non-OpenHands to OpenHands provider in SaaS mode", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "saas", + // SaaS mode is already the default from beforeEach, but let's be explicit + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -497,10 +609,10 @@ describe("Content", () => { }); it("should show API key input when switching from OpenHands to non-OpenHands provider in SaaS mode", async () => { - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - only return app_mode for these tests - getConfigSpy.mockResolvedValue({ - app_mode: "saas", + // SaaS mode is already the default from beforeEach, but let's be explicit + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, }); renderLlmSettingsScreen(); @@ -548,15 +660,17 @@ describe("Form submission", () => { const provider = screen.getByTestId("llm-provider-input"); const model = screen.getByTestId("llm-model-input"); - const apiKey = screen.getByTestId("llm-api-key-input"); - // select provider + // select provider (switch to OpenAI so API key input becomes visible) await userEvent.click(provider); const providerOption = screen.getByText("OpenAI"); await userEvent.click(providerOption); - expect(provider).toHaveValue("OpenAI"); + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); - // enter api key + // enter api key (now visible after switching provider) + const apiKey = await screen.findByTestId("llm-api-key-input"); await userEvent.type(apiKey, "test-api-key"); // select model @@ -577,6 +691,12 @@ describe("Form submission", () => { }); it("should submit the advanced form with the correct values", async () => { + // Use OSS mode so agent-input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); renderLlmSettingsScreen(); @@ -685,6 +805,12 @@ describe("Form submission", () => { }); it("should disable the button if there are no changes in the advanced form", async () => { + // Use OSS mode so agent-input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, @@ -818,8 +944,17 @@ describe("Form submission", () => { expect(submitButton).toBeDisabled(); + // Switch to a non-OpenHands provider first so API key input is visible + const provider = screen.getByTestId("llm-provider-input"); + await userEvent.click(provider); + const providerOption = screen.getByText("OpenAI"); + await userEvent.click(providerOption); + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); + // dirty the basic form - const apiKey = screen.getByTestId("llm-api-key-input"); + const apiKey = await screen.findByTestId("llm-api-key-input"); await userEvent.type(apiKey, "test-api-key"); expect(submitButton).not.toBeDisabled(); @@ -1009,21 +1144,9 @@ describe("View persistence after saving advanced settings", () => { it("should remain on Advanced view after saving when search API key is set", async () => { // Arrange: Start with default settings (non-SaaS mode to show search API key field) - const getConfigSpy = vi.spyOn(OptionService, "getConfig"); - // @ts-expect-error - partial mock for testing - getConfigSpy.mockResolvedValue({ - app_mode: "oss", - posthog_client_key: "fake-posthog-client-key", - feature_flags: { - enable_billing: false, - hide_llm_settings: false, - enable_jira: false, - enable_jira_dc: false, - enable_linear: false, - hide_users_page: false, - hide_billing_page: false, - hide_integrations_page: false, - }, + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, }); const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); @@ -1080,12 +1203,37 @@ describe("Status toasts", () => { ); renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); - // Toggle setting to change + // Switch to a non-OpenHands provider so API key input is visible + const provider = screen.getByTestId("llm-provider-input"); + await userEvent.click(provider); + const providerOption = screen.getByText("OpenAI"); + await userEvent.click(providerOption); + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); + + // Wait for API key input to appear const apiKeyInput = await screen.findByTestId("llm-api-key-input"); + + // Also change the model to ensure form is dirty + const model = screen.getByTestId("llm-model-input"); + await userEvent.click(model); + const modelOption = screen.getByText("gpt-4o"); + await userEvent.click(modelOption); + await waitFor(() => { + expect(model).toHaveValue("gpt-4o"); + }); + + // Enter API key await userEvent.type(apiKeyInput, "test-api-key"); + // Wait for submit button to be enabled const submit = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submit).not.toBeDisabled(); + }); await userEvent.click(submit); expect(saveSettingsSpy).toHaveBeenCalled(); @@ -1100,12 +1248,37 @@ describe("Status toasts", () => { saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings")); renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); - // Toggle setting to change + // Switch to a non-OpenHands provider so API key input is visible + const provider = screen.getByTestId("llm-provider-input"); + await userEvent.click(provider); + const providerOption = screen.getByText("OpenAI"); + await userEvent.click(providerOption); + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); + + // Wait for API key input to appear const apiKeyInput = await screen.findByTestId("llm-api-key-input"); + + // Also change the model to ensure form is dirty + const model = screen.getByTestId("llm-model-input"); + await userEvent.click(model); + const modelOption = screen.getByText("gpt-4o"); + await userEvent.click(modelOption); + await waitFor(() => { + expect(model).toHaveValue("gpt-4o"); + }); + + // Enter API key await userEvent.type(apiKeyInput, "test-api-key"); + // Wait for submit button to be enabled const submit = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submit).not.toBeDisabled(); + }); await userEvent.click(submit); expect(saveSettingsSpy).toHaveBeenCalled(); @@ -1115,6 +1288,12 @@ describe("Status toasts", () => { describe("Advanced form", () => { it("should call displaySuccessToast when the settings are saved", async () => { + // Use OSS mode to ensure API key input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); const displaySuccessToastSpy = vi.spyOn( @@ -1133,7 +1312,11 @@ describe("Status toasts", () => { const apiKeyInput = await screen.findByTestId("llm-api-key-input"); await userEvent.type(apiKeyInput, "test-api-key"); + // Wait for submit button to be enabled const submit = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submit).not.toBeDisabled(); + }); await userEvent.click(submit); expect(saveSettingsSpy).toHaveBeenCalled(); @@ -1141,6 +1324,12 @@ describe("Status toasts", () => { }); it("should call displayErrorToast when the settings fail to save", async () => { + // Use OSS mode to ensure API key input is visible + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); @@ -1158,7 +1347,11 @@ describe("Status toasts", () => { const apiKeyInput = await screen.findByTestId("llm-api-key-input"); await userEvent.type(apiKeyInput, "test-api-key"); + // Wait for submit button to be enabled const submit = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submit).not.toBeDisabled(); + }); await userEvent.click(submit); expect(saveSettingsSpy).toHaveBeenCalled(); @@ -1166,3 +1359,411 @@ describe("Status toasts", () => { }); }); }); + +describe("Role-based permissions", () => { + const getMeSpy = vi.spyOn(organizationService, "getMe"); + + beforeEach(() => { + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, + }); + }); + + describe("User role (read-only)", () => { + const memberData: OrganizationMember = { + org_id: "2", + user_id: "99", + email: "user@example.com", + role: "member", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }; + + beforeEach(() => { + // Mock user role + getMeSpy.mockResolvedValue(memberData); + }); + + it("should disable all input fields in basic view", async () => { + // Arrange + renderLlmSettingsScreen("2", memberData); // orgId "2" returns user role + + // Act + await screen.findByTestId("llm-settings-screen"); + const basicForm = screen.getByTestId("llm-settings-form-basic"); + + // Assert + const providerInput = within(basicForm).getByTestId("llm-provider-input"); + const modelInput = within(basicForm).getByTestId("llm-model-input"); + + await waitFor(() => { + expect(providerInput).toBeDisabled(); + expect(modelInput).toBeDisabled(); + }); + + // API key input may be hidden if OpenHands provider is selected in SaaS mode + // If it exists, it should be disabled + const apiKeyInput = within(basicForm).queryByTestId("llm-api-key-input"); + if (apiKeyInput) { + expect(apiKeyInput).toBeDisabled(); + } + }); + + // Note: No "should disable all input fields in advanced view" test for members + // because members cannot access the advanced view (the toggle is disabled). + + it("should not render submit button", async () => { + // Arrange + renderLlmSettingsScreen("2", memberData); + + // Act + await screen.findByTestId("llm-settings-screen"); + const submitButton = screen.queryByTestId("submit-button"); + + // Assert + expect(submitButton).not.toBeInTheDocument(); + }); + + it("should disable the advanced/basic toggle for read-only users", async () => { + // Arrange + renderLlmSettingsScreen("2", memberData); + + // Act + await screen.findByTestId("llm-settings-screen"); + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + + // Assert - toggle should be disabled for members who lack edit_llm_settings + await waitFor(() => { + expect(advancedSwitch).toBeDisabled(); + }); + + // Basic form should remain visible (members can't switch to advanced) + expect( + screen.getByTestId("llm-settings-form-basic"), + ).toBeInTheDocument(); + }); + + }); + + describe("Owner role (full access)", () => { + beforeEach(() => { + // Mock owner role + getMeSpy.mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "owner@example.com", + role: "owner", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }); + }); + + it("should enable all input fields in basic view", async () => { + // Arrange + renderLlmSettingsScreen("1"); // orgId "1" returns owner role + + // Act + await screen.findByTestId("llm-settings-screen"); + const basicForm = screen.getByTestId("llm-settings-form-basic"); + + // Assert + const providerInput = within(basicForm).getByTestId("llm-provider-input"); + const modelInput = within(basicForm).getByTestId("llm-model-input"); + + await waitFor(() => { + expect(providerInput).not.toBeDisabled(); + expect(modelInput).not.toBeDisabled(); + }); + + // API key input may be hidden if OpenHands provider is selected in SaaS mode + // If it exists, it should be enabled + const apiKeyInput = within(basicForm).queryByTestId("llm-api-key-input"); + if (apiKeyInput) { + expect(apiKeyInput).not.toBeDisabled(); + } + }); + + it("should enable all input fields in advanced view", async () => { + // Arrange + renderLlmSettingsScreen("1"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + + // Assert - owners can toggle between views + expect(advancedSwitch).not.toBeDisabled(); + + await userEvent.click(advancedSwitch); + const advancedForm = await screen.findByTestId( + "llm-settings-form-advanced", + ); + + // Assert + const modelInput = within(advancedForm).getByTestId( + "llm-custom-model-input", + ); + const baseUrlInput = within(advancedForm).getByTestId("base-url-input"); + const condenserSwitch = within(advancedForm).getByTestId( + "enable-memory-condenser-switch", + ); + const confirmationSwitch = within(advancedForm).getByTestId( + "enable-confirmation-mode-switch", + ); + + await waitFor(() => { + expect(modelInput).not.toBeDisabled(); + expect(baseUrlInput).not.toBeDisabled(); + expect(condenserSwitch).not.toBeDisabled(); + expect(confirmationSwitch).not.toBeDisabled(); + }); + + // API key input may be hidden if OpenHands provider is selected in SaaS mode + // If it exists, it should be enabled + const apiKeyInput = + within(advancedForm).queryByTestId("llm-api-key-input"); + if (apiKeyInput) { + expect(apiKeyInput).not.toBeDisabled(); + } + }); + + it("should enable submit button when form is dirty", async () => { + // Arrange + renderLlmSettingsScreen("1"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const submitButton = screen.getByTestId("submit-button"); + const providerInput = screen.getByTestId("llm-provider-input"); + + // Assert - initially disabled (no changes) + expect(submitButton).toBeDisabled(); + + // Act - make a change by selecting a different provider + await userEvent.click(providerInput); + const openAIOption = await screen.findByText("OpenAI"); + await userEvent.click(openAIOption); + + // Assert - button should be enabled + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + }); + + it("should allow submitting form changes", async () => { + // Arrange + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); + renderLlmSettingsScreen("1"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const providerInput = screen.getByTestId("llm-provider-input"); + const modelInput = screen.getByTestId("llm-model-input"); + + // Select a different provider to make form dirty + await userEvent.click(providerInput); + const openAIOption = await screen.findByText("OpenAI"); + await userEvent.click(openAIOption); + await waitFor(() => { + expect(providerInput).toHaveValue("OpenAI"); + }); + + // Select a different model to ensure form is dirty + await userEvent.click(modelInput); + const modelOption = await screen.findByText("gpt-4o"); + await userEvent.click(modelOption); + await waitFor(() => { + expect(modelInput).toHaveValue("gpt-4o"); + }); + + // Wait for form to be marked as dirty + const submitButton = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + + await userEvent.click(submitButton); + + // Assert + await waitFor(() => { + expect(saveSettingsSpy).toHaveBeenCalled(); + }); + }); + + // Note: The former "should disable security analyzer dropdown when confirmation mode + // is enabled" test was removed. It was in the member block and only passed because + // members have isReadOnly=true (all fields disabled), not because confirmation mode + // disables the analyzer. For owners/admins, the security analyzer is enabled + // regardless of confirmation mode. + }); + + describe("Admin role (full access)", () => { + beforeEach(() => { + // Mock admin role + getMeSpy.mockResolvedValue({ + org_id: "3", + user_id: "99", + email: "admin@example.com", + role: "admin", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }); + }); + + it("should enable all input fields in basic view", async () => { + // Arrange + renderLlmSettingsScreen("3"); // orgId "3" returns admin role + + // Act + await screen.findByTestId("llm-settings-screen"); + const basicForm = screen.getByTestId("llm-settings-form-basic"); + + // Assert + const providerInput = within(basicForm).getByTestId("llm-provider-input"); + const modelInput = within(basicForm).getByTestId("llm-model-input"); + + await waitFor(() => { + expect(providerInput).not.toBeDisabled(); + expect(modelInput).not.toBeDisabled(); + }); + + // API key input may be hidden if OpenHands provider is selected in SaaS mode + // If it exists, it should be enabled + const apiKeyInput = within(basicForm).queryByTestId("llm-api-key-input"); + if (apiKeyInput) { + expect(apiKeyInput).not.toBeDisabled(); + } + }); + + it("should enable all input fields in advanced view", async () => { + // Arrange + renderLlmSettingsScreen("3"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + + // Assert - admins can toggle between views + expect(advancedSwitch).not.toBeDisabled(); + + await userEvent.click(advancedSwitch); + const advancedForm = await screen.findByTestId( + "llm-settings-form-advanced", + ); + + // Assert + const modelInput = within(advancedForm).getByTestId( + "llm-custom-model-input", + ); + const baseUrlInput = within(advancedForm).getByTestId("base-url-input"); + const condenserSwitch = within(advancedForm).getByTestId( + "enable-memory-condenser-switch", + ); + const confirmationSwitch = within(advancedForm).getByTestId( + "enable-confirmation-mode-switch", + ); + + await waitFor(() => { + expect(modelInput).not.toBeDisabled(); + expect(baseUrlInput).not.toBeDisabled(); + expect(condenserSwitch).not.toBeDisabled(); + expect(confirmationSwitch).not.toBeDisabled(); + }); + + // API key input may be hidden if OpenHands provider is selected in SaaS mode + // If it exists, it should be enabled + const apiKeyInput = + within(advancedForm).queryByTestId("llm-api-key-input"); + if (apiKeyInput) { + expect(apiKeyInput).not.toBeDisabled(); + } + }); + + it("should enable submit button when form is dirty", async () => { + // Arrange + renderLlmSettingsScreen("3"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const submitButton = screen.getByTestId("submit-button"); + const providerInput = screen.getByTestId("llm-provider-input"); + + // Assert - initially disabled (no changes) + expect(submitButton).toBeDisabled(); + + // Act - make a change by selecting a different provider + await userEvent.click(providerInput); + const openAIOption = await screen.findByText("OpenAI"); + await userEvent.click(openAIOption); + + // Assert - button should be enabled + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + }); + + it("should allow submitting form changes", async () => { + // Arrange + const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); + renderLlmSettingsScreen("3"); + + // Act + await screen.findByTestId("llm-settings-screen"); + const providerInput = screen.getByTestId("llm-provider-input"); + const modelInput = screen.getByTestId("llm-model-input"); + + // Select a different provider to make form dirty + await userEvent.click(providerInput); + const openAIOption = await screen.findByText("OpenAI"); + await userEvent.click(openAIOption); + await waitFor(() => { + expect(providerInput).toHaveValue("OpenAI"); + }); + + // Select a different model to ensure form is dirty + await userEvent.click(modelInput); + const modelOption = await screen.findByText("gpt-4o"); + await userEvent.click(modelOption); + await waitFor(() => { + expect(modelInput).toHaveValue("gpt-4o"); + }); + + // Wait for form to be marked as dirty + const submitButton = await screen.findByTestId("submit-button"); + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + }); + + await userEvent.click(submitButton); + + // Assert + await waitFor(() => { + expect(saveSettingsSpy).toHaveBeenCalled(); + }); + }); + }); +}); + +describe("clientLoader permission checks", () => { + it("should export a clientLoader for route protection", async () => { + // This test verifies the clientLoader is exported for consistency with other routes + // Note: All roles have view_llm_settings permission, so this guard ensures + // the route is protected and can be restricted in the future if needed + const { clientLoader } = await import("#/routes/llm-settings"); + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); +}); diff --git a/frontend/__tests__/routes/manage-org.test.tsx b/frontend/__tests__/routes/manage-org.test.tsx new file mode 100644 index 0000000000..390b10fc43 --- /dev/null +++ b/frontend/__tests__/routes/manage-org.test.tsx @@ -0,0 +1,954 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub } from "react-router"; +import { selectOrganization } from "test-utils"; +import ManageOrg from "#/routes/manage-org"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import SettingsScreen, { clientLoader } from "#/routes/settings"; +import { + resetOrgMockData, + MOCK_TEAM_ORG_ACME, + INITIAL_MOCK_ORGS, +} from "#/mocks/org-handlers"; +import OptionService from "#/api/option-service/option-service.api"; +import BillingService from "#/api/billing-service/billing-service.api"; +import { OrganizationMember } from "#/types/org"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import { createMockWebClientConfig } from "#/mocks/settings-handlers"; + +const mockQueryClient = vi.hoisted(() => { + const { QueryClient } = require("@tanstack/react-query"); + return new QueryClient(); +}); + +vi.mock("#/query-client-config", () => ({ + queryClient: mockQueryClient, +})); + +function ManageOrgWithPortalRoot() { + return ( +
+ +
+
+ ); +} + +const RouteStub = createRoutesStub([ + { + Component: () =>
, + path: "/", + }, + { + // @ts-expect-error - type mismatch + loader: clientLoader, + Component: SettingsScreen, + path: "/settings", + HydrateFallback: () =>
Loading...
, + children: [ + { + Component: ManageOrgWithPortalRoot, + path: "/settings/org", + }, + ], + }, +]); + +let queryClient: QueryClient; + +const renderManageOrg = () => + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + +const { navigateMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), +})); + +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization", + ORG$PERSONAL_WORKSPACE: "Personal Workspace", + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +vi.mock("react-router", async () => ({ + ...(await vi.importActual("react-router")), + useNavigate: () => navigateMock, +})); + +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => ({ data: true }), +})); + +describe("Manage Org Route", () => { + const getMeSpy = vi.spyOn(organizationService, "getMe"); + + // Test data constants + const TEST_USERS: Record<"OWNER" | "ADMIN", OrganizationMember> = { + OWNER: { + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + ADMIN: { + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + }; + + // Helper function to set up user mock + const setupUserMock = (userData: { + org_id: string; + user_id: string; + email: string; + role: "owner" | "admin" | "member"; + llm_api_key: string; + max_iterations: number; + llm_model: string; + llm_api_key_for_byor: string | null; + llm_base_url: string; + status: "active" | "invited" | "inactive"; + }) => { + getMeSpy.mockResolvedValue(userData); + }; + + beforeEach(() => { + // Set Zustand store to a team org so clientLoader's org route protection allows access + useSelectedOrganizationStore.setState({ + organizationId: MOCK_TEAM_ORG_ACME.id, + }); + // Seed organizations into the module-level queryClient used by clientLoader + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + queryClient = new QueryClient(); + // Pre-seed organizations so org selector renders immediately (avoids flaky race with API fetch) + queryClient.setQueryData(["organizations"], { + items: INITIAL_MOCK_ORGS, + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, // Enable billing by default so billing UI is shown + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + // Set default mock for user (owner role has all permissions) + setupUserMock(TEST_USERS.OWNER); + }); + + afterEach(() => { + vi.clearAllMocks(); + // Reset organization mock data to ensure clean state between tests + resetOrgMockData(); + // Reset Zustand store to ensure clean state between tests + useSelectedOrganizationStore.setState({ organizationId: null }); + // Clear module-level queryClient used by clientLoader + mockQueryClient.clear(); + // Clear test queryClient + queryClient?.clear(); + }); + + it("should render the available credits", async () => { + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + await waitFor(() => { + const credits = screen.getByTestId("available-credits"); + expect(credits).toHaveTextContent("100"); + }); + }); + + it("should render account details", async () => { + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + await waitFor(() => { + const orgName = screen.getByTestId("org-name"); + expect(orgName).toHaveTextContent("Personal Workspace"); + }); + }); + + it("should be able to add credits", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); // user is owner in org 1 + + expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument(); + // Simulate adding credits — wait for permissions-dependent button + const addCreditsButton = await waitFor(() => screen.getByText(/add/i)); + await userEvent.click(addCreditsButton); + + const addCreditsForm = screen.getByTestId("add-credits-form"); + expect(addCreditsForm).toBeInTheDocument(); + + const amountInput = within(addCreditsForm).getByTestId("amount-input"); + const nextButton = within(addCreditsForm).getByRole("button", { + name: /next/i, + }); + + await userEvent.type(amountInput, "1000"); + await userEvent.click(nextButton); + + // expect redirect to payment page + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000); + + await waitFor(() => + expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument(), + ); + }); + + it("should close the modal when clicking cancel", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); // user is owner in org 1 + + expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument(); + // Simulate adding credits — wait for permissions-dependent button + const addCreditsButton = await waitFor(() => screen.getByText(/add/i)); + await userEvent.click(addCreditsButton); + + const addCreditsForm = screen.getByTestId("add-credits-form"); + expect(addCreditsForm).toBeInTheDocument(); + + const cancelButton = within(addCreditsForm).getByRole("button", { + name: /close/i, + }); + + await userEvent.click(cancelButton); + + expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument(); + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + + describe("AddCreditsModal", () => { + const openAddCreditsModal = async () => { + const user = userEvent.setup(); + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); // user is owner in org 1 + + const addCreditsButton = await waitFor(() => screen.getByText(/add/i)); + await user.click(addCreditsButton); + + const addCreditsForm = screen.getByTestId("add-credits-form"); + expect(addCreditsForm).toBeInTheDocument(); + + return { user, addCreditsForm }; + }; + + describe("Button State Management", () => { + it("should enable submit button initially when modal opens", async () => { + await openAddCreditsModal(); + + const nextButton = screen.getByRole("button", { name: /next/i }); + expect(nextButton).not.toBeDisabled(); + }); + + it("should enable submit button when input contains invalid value", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "-50"); + + expect(nextButton).not.toBeDisabled(); + }); + + it("should enable submit button when input contains valid value", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "100"); + + expect(nextButton).not.toBeDisabled(); + }); + + it("should enable submit button after validation error is shown", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "9"); + await user.click(nextButton); + + await waitFor(() => { + expect(screen.getByTestId("amount-error")).toBeInTheDocument(); + }); + + expect(nextButton).not.toBeDisabled(); + }); + }); + + describe("Input Attributes & Placeholder", () => { + it("should have min attribute set to 10", async () => { + await openAddCreditsModal(); + + const amountInput = screen.getByTestId("amount-input"); + expect(amountInput).toHaveAttribute("min", "10"); + }); + + it("should have max attribute set to 25000", async () => { + await openAddCreditsModal(); + + const amountInput = screen.getByTestId("amount-input"); + expect(amountInput).toHaveAttribute("max", "25000"); + }); + + it("should have step attribute set to 1", async () => { + await openAddCreditsModal(); + + const amountInput = screen.getByTestId("amount-input"); + expect(amountInput).toHaveAttribute("step", "1"); + }); + }); + + describe("Error Message Display", () => { + it("should not display error message initially when modal opens", async () => { + await openAddCreditsModal(); + + const errorMessage = screen.queryByTestId("amount-error"); + expect(errorMessage).not.toBeInTheDocument(); + }); + + it("should display error message after submitting amount above maximum", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "25001"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MAXIMUM_AMOUNT", + ); + }); + }); + + it("should display error message after submitting decimal value", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "50.5"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER", + ); + }); + }); + + it("should replace error message when submitting different invalid value", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "9"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MINIMUM_AMOUNT", + ); + }); + + await user.clear(amountInput); + await user.type(amountInput, "25001"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MAXIMUM_AMOUNT", + ); + }); + }); + }); + + describe("Form Submission Behavior", () => { + it("should prevent submission when amount is invalid", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "9"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MINIMUM_AMOUNT", + ); + }); + }); + + it("should call createCheckoutSession with correct amount when valid", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "1000"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000); + const errorMessage = screen.queryByTestId("amount-error"); + expect(errorMessage).not.toBeInTheDocument(); + }); + + it("should not call createCheckoutSession when validation fails", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "-50"); + await user.click(nextButton); + + // Verify mutation was not called + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_NEGATIVE_AMOUNT", + ); + }); + }); + + it("should close modal on successful submission", async () => { + const createCheckoutSessionSpy = vi + .spyOn(BillingService, "createCheckoutSession") + .mockResolvedValue("https://checkout.stripe.com/test-session"); + + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "1000"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000); + + await waitFor(() => { + expect( + screen.queryByTestId("add-credits-form"), + ).not.toBeInTheDocument(); + }); + }); + + it("should allow API call when validation passes and clear any previous errors", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + // First submit invalid value + await user.type(amountInput, "9"); + await user.click(nextButton); + + await waitFor(() => { + expect(screen.getByTestId("amount-error")).toBeInTheDocument(); + }); + + // Then submit valid value + await user.clear(amountInput); + await user.type(amountInput, "100"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100); + const errorMessage = screen.queryByTestId("amount-error"); + expect(errorMessage).not.toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("should handle zero value correctly", async () => { + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + await user.type(amountInput, "0"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent( + "PAYMENT$ERROR_MINIMUM_AMOUNT", + ); + }); + }); + + it("should handle whitespace-only input correctly", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = await openAddCreditsModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /next/i }); + + // Number inputs typically don't accept spaces, but test the behavior + await user.type(amountInput, " "); + await user.click(nextButton); + + // Should not call API (empty/invalid input) + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + }); + }); + + it("should show add credits option for ADMIN role", async () => { + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI) + + // Verify credits are shown + await waitFor(() => { + const credits = screen.getByTestId("available-credits"); + expect(credits).toBeInTheDocument(); + }); + + // Verify add credits button is present (admins can add credits) + const addButton = screen.getByText(/add/i); + expect(addButton).toBeInTheDocument(); + }); + + describe("actions", () => { + it("should be able to update the organization name", async () => { + const updateOrgNameSpy = vi.spyOn( + organizationService, + "updateOrganization", + ); + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", // required to enable getMe + }), + ); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + const orgName = screen.getByTestId("org-name"); + await waitFor(() => + expect(orgName).toHaveTextContent("Personal Workspace"), + ); + + expect( + screen.queryByTestId("update-org-name-form"), + ).not.toBeInTheDocument(); + + const changeOrgNameButton = within(orgName).getByRole("button", { + name: /change/i, + }); + await userEvent.click(changeOrgNameButton); + + const orgNameForm = screen.getByTestId("update-org-name-form"); + const orgNameInput = within(orgNameForm).getByRole("textbox"); + const saveButton = within(orgNameForm).getByRole("button", { + name: /save/i, + }); + + await userEvent.type(orgNameInput, "New Org Name"); + await userEvent.click(saveButton); + + expect(updateOrgNameSpy).toHaveBeenCalledWith({ + orgId: "1", + name: "New Org Name", + }); + + await waitFor(() => { + expect( + screen.queryByTestId("update-org-name-form"), + ).not.toBeInTheDocument(); + expect(orgName).toHaveTextContent("New Org Name"); + }); + }); + + it("should NOT allow roles other than owners to change org name", async () => { + // Set admin role before rendering + setupUserMock(TEST_USERS.ADMIN); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI) + + const orgName = screen.getByTestId("org-name"); + const changeOrgNameButton = within(orgName).queryByRole("button", { + name: /change/i, + }); + expect(changeOrgNameButton).not.toBeInTheDocument(); + }); + + it("should NOT allow roles other than owners to delete an organization", async () => { + setupUserMock(TEST_USERS.ADMIN); + + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", // required to enable getMe + }), + ); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI) + + const deleteOrgButton = screen.queryByRole("button", { + name: /ORG\$DELETE_ORGANIZATION/i, + }); + expect(deleteOrgButton).not.toBeInTheDocument(); + }); + + it("should be able to delete an organization", async () => { + const deleteOrgSpy = vi.spyOn(organizationService, "deleteOrganization"); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + expect( + screen.queryByTestId("delete-org-confirmation"), + ).not.toBeInTheDocument(); + + const deleteOrgButton = await waitFor(() => + screen.getByRole("button", { + name: /ORG\$DELETE_ORGANIZATION/i, + }), + ); + await userEvent.click(deleteOrgButton); + + const deleteConfirmation = screen.getByTestId("delete-org-confirmation"); + const confirmButton = within(deleteConfirmation).getByRole("button", { + name: /BUTTON\$CONFIRM/i, + }); + + await userEvent.click(confirmButton); + + expect(deleteOrgSpy).toHaveBeenCalledWith({ orgId: "1" }); + expect( + screen.queryByTestId("delete-org-confirmation"), + ).not.toBeInTheDocument(); + + // expect to have navigated to home screen + await screen.findByTestId("home-screen"); + }); + + it.todo("should be able to update the organization billing info"); + }); + + describe("Role-based delete organization permission behavior", () => { + it("should show delete organization button when user has canDeleteOrganization permission (Owner role)", async () => { + setupUserMock(TEST_USERS.OWNER); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + const deleteButton = await screen.findByRole("button", { + name: /ORG\$DELETE_ORGANIZATION/i, + }); + + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).not.toBeDisabled(); + }); + + it("should not show delete organization button when user lacks canDeleteOrganization permission ('Admin' role)", async () => { + setupUserMock({ + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + const deleteButton = screen.queryByRole("button", { + name: /ORG\$DELETE_ORGANIZATION/i, + }); + + expect(deleteButton).not.toBeInTheDocument(); + }); + + it("should not show delete organization button when user lacks canDeleteOrganization permission ('Member' role)", async () => { + setupUserMock({ + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + // Members lack view_billing permission, so the clientLoader redirects away from /settings/org + renderManageOrg(); + + // The manage-org screen should NOT be accessible — clientLoader redirects + await waitFor(() => { + expect( + screen.queryByTestId("manage-org-screen"), + ).not.toBeInTheDocument(); + }); + }); + + it("should open delete confirmation modal when delete button is clicked (with permission)", async () => { + setupUserMock(TEST_USERS.OWNER); + + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + + await selectOrganization({ orgIndex: 0 }); + + expect( + screen.queryByTestId("delete-org-confirmation"), + ).not.toBeInTheDocument(); + + const deleteButton = await screen.findByRole("button", { + name: /ORG\$DELETE_ORGANIZATION/i, + }); + await userEvent.click(deleteButton); + + expect(screen.getByTestId("delete-org-confirmation")).toBeInTheDocument(); + }); + }); + + describe("enable_billing feature flag", () => { + it("should show credits section when enable_billing is true", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + // Act + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + await selectOrganization({ orgIndex: 0 }); + + // Assert + await waitFor(() => { + expect(screen.getByTestId("available-credits")).toBeInTheDocument(); + }); + + getConfigSpy.mockRestore(); + }); + + it("should show organization name section when enable_billing is true", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + // Act + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + await selectOrganization({ orgIndex: 0 }); + + // Assert + await waitFor(() => { + expect(screen.getByTestId("org-name")).toBeInTheDocument(); + }); + + getConfigSpy.mockRestore(); + }); + + it("should show Add Credits button when enable_billing is true", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + // Act + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + await selectOrganization({ orgIndex: 0 }); + + // Assert + await waitFor(() => { + const addButton = screen.getByText(/add/i); + expect(addButton).toBeInTheDocument(); + }); + + getConfigSpy.mockRestore(); + }); + + it("should hide all billing-related elements when enable_billing is false", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + // Act + renderManageOrg(); + await screen.findByTestId("manage-org-screen"); + await selectOrganization({ orgIndex: 0 }); + + // Assert + await waitFor(() => { + expect( + screen.queryByTestId("available-credits"), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/add/i)).not.toBeInTheDocument(); + }); + + getConfigSpy.mockRestore(); + }); + }); +}); diff --git a/frontend/__tests__/routes/manage-organization-members.test.tsx b/frontend/__tests__/routes/manage-organization-members.test.tsx new file mode 100644 index 0000000000..391fef1b44 --- /dev/null +++ b/frontend/__tests__/routes/manage-organization-members.test.tsx @@ -0,0 +1,1062 @@ +import { describe, expect, it, vi, test, beforeEach, afterEach } from "vitest"; +import { render, screen, within, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub } from "react-router"; +import { selectOrganization } from "test-utils"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import ManageOrganizationMembers from "#/routes/manage-organization-members"; +import SettingsScreen, { + clientLoader as settingsClientLoader, +} from "#/routes/settings"; +import { + ORGS_AND_MEMBERS, + resetOrgMockData, + resetOrgsAndMembersMockData, + MOCK_TEAM_ORG_ACME, + INITIAL_MOCK_ORGS, +} from "#/mocks/org-handlers"; +import OptionService from "#/api/option-service/option-service.api"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import { createMockWebClientConfig } from "#/mocks/settings-handlers"; + +const mockQueryClient = vi.hoisted(() => { + const { QueryClient } = require("@tanstack/react-query"); + return new QueryClient(); +}); + +vi.mock("#/query-client-config", () => ({ + queryClient: mockQueryClient, +})); + +vi.mock("react-i18next", async () => { + const actual = + await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization", + ORG$PERSONAL_WORKSPACE: "Personal Workspace", + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: vi.fn(), + }, + }), + }; +}); + +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => ({ data: true }), +})); + +function ManageOrganizationMembersWithPortalRoot() { + return ( +
+ +
+
+ ); +} + +const RouteStub = createRoutesStub([ + { + // @ts-expect-error - ignoreing error for test stub + loader: settingsClientLoader, + Component: SettingsScreen, + path: "/settings", + HydrateFallback: () =>
Loading...
, + children: [ + { + Component: ManageOrganizationMembersWithPortalRoot, + path: "/settings/org-members", + handle: { hideTitle: true }, + }, + { + Component: () =>
, + path: "/settings/member", + }, + ], + }, +]); + +let queryClient: QueryClient; + +describe("Manage Organization Members Route", () => { + const getMeSpy = vi.spyOn(organizationService, "getMe"); + + beforeEach(() => { + // Set Zustand store to a team org so clientLoader allows access to /settings/org-members + useSelectedOrganizationStore.setState({ + organizationId: MOCK_TEAM_ORG_ACME.id, + }); + // Seed organizations into the module-level queryClient used by clientLoader + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + queryClient = new QueryClient(); + + // Pre-seed organizations so org selector renders immediately (avoids flaky race with API fetch) + queryClient.setQueryData(["organizations"], { + items: INITIAL_MOCK_ORGS, + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + // Set default mock for user (admin role has invite permission) + getMeSpy.mockResolvedValue({ + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + // Reset organization mock data to ensure clean state between tests + resetOrgMockData(); + // Reset ORGS_AND_MEMBERS to initial state + resetOrgsAndMembersMockData(); + // Clear queryClient cache to ensure fresh data for next test + queryClient.clear(); + // Reset Zustand store and module-level queryClient + useSelectedOrganizationStore.setState({ organizationId: null }); + mockQueryClient.clear(); + }); + + const renderManageOrganizationMembers = () => + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Helper function to find a member by email + const findMemberByEmail = async (email: string) => { + const memberListItems = await screen.findAllByTestId("member-item"); + const member = memberListItems.find((item) => + within(item).queryByText(email), + ); + if (!member) { + throw new Error(`Could not find member with email: ${email}`); + } + return member; + }; + + // Helper function to open role dropdown for a member + const openRoleDropdown = async ( + memberElement: HTMLElement, + roleText: string, + ) => { + // Find the role text that's clickable (has cursor-pointer class or is the main role display) + // Use a more specific query to avoid matching dropdown options + const roleElement = within(memberElement).getByText( + new RegExp(`^${roleText}$`, "i"), + ); + await userEvent.click(roleElement); + return within(memberElement).getByTestId( + "organization-member-role-context-menu", + ); + }; + + // Helper function to change member role + const changeMemberRole = async ( + memberElement: HTMLElement, + currentRole: string, + newRole: string, + ) => { + const dropdown = await openRoleDropdown(memberElement, currentRole); + const roleOption = within(dropdown).getByText(new RegExp(newRole, "i")); + await userEvent.click(roleOption); + + // If role is changing, confirm the modal + if (currentRole.toLowerCase() !== newRole.toLowerCase()) { + const confirmButton = await screen.findByTestId("confirm-button"); + await userEvent.click(confirmButton); + } + }; + + // Helper function to verify dropdown is not visible + const expectDropdownNotVisible = (memberElement: HTMLElement) => { + expect( + within(memberElement).queryByTestId( + "organization-member-role-context-menu", + ), + ).not.toBeInTheDocument(); + }; + + // Helper function to setup test with user and organization + const setupTestWithUserAndOrg = async ( + userData: { + org_id: string; + user_id: string; + email: string; + role: "owner" | "admin" | "member"; + llm_api_key: string; + max_iterations: number; + llm_model: string; + llm_api_key_for_byor: string | null; + llm_base_url: string; + status: "active" | "invited" | "inactive"; + }, + orgIndex: number, + ) => { + getMeSpy.mockResolvedValue(userData); + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex }); + // Wait for member list to be rendered (async data loaded) + await screen.findAllByTestId("member-item"); + }; + + // Helper function to create updateMember spy + const createUpdateMemberRoleSpy = () => + vi.spyOn(organizationService, "updateMember"); + + // Helper function to verify role change is not permitted + const verifyRoleChangeNotPermitted = async ( + userData: { + org_id: string; + user_id: string; + email: string; + role: "owner" | "admin" | "member"; + llm_api_key: string; + max_iterations: number; + llm_model: string; + llm_api_key_for_byor: string | null; + llm_base_url: string; + status: "active" | "invited" | "inactive"; + }, + orgIndex: number, + targetMemberIndex: number, + expectedRoleText: string, + ) => { + await setupTestWithUserAndOrg(userData, orgIndex); + + const memberListItems = await screen.findAllByTestId("member-item"); + const targetMember = memberListItems[targetMemberIndex]; + const roleText = within(targetMember).getByText( + new RegExp(`^${expectedRoleText}$`, "i"), + ); + expect(roleText).toBeInTheDocument(); + await userEvent.click(roleText); + + // Verify that the dropdown does not open + expectDropdownNotVisible(targetMember); + }; + + // Helper function to setup invite test (render and select organization) + const setupInviteTest = async (orgIndex: number = 0) => { + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex }); + }; + + // Helper function to setup test with organization (waits for settings screen) + const setupTestWithOrg = async (orgIndex: number = 0) => { + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex }); + }; + + // Helper function to find invite button + const findInviteButton = () => + screen.findByRole("button", { + name: /ORG\$INVITE_ORG_MEMBERS/i, + }); + + // Helper function to verify all three role options are present in dropdown + const expectAllRoleOptionsPresent = (dropdown: HTMLElement) => { + expect(within(dropdown).getByText(/owner/i)).toBeInTheDocument(); + expect(within(dropdown).getByText(/admin/i)).toBeInTheDocument(); + expect(within(dropdown).getByText(/member/i)).toBeInTheDocument(); + }; + + // Helper function to close dropdown by clicking outside + const closeDropdown = async () => { + await userEvent.click(document.body); + }; + + // Helper function to verify owner option is not present in dropdown + const expectOwnerOptionNotPresent = (dropdown: HTMLElement) => { + expect(within(dropdown).queryByText(/owner/i)).not.toBeInTheDocument(); + }; + + it("should render", async () => { + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + }); + + it("should navigate away from the page if not saas", async () => { + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + // @ts-expect-error - partial mock for testing + getConfigSpy.mockResolvedValue({ + app_mode: "oss", + }); + + renderManageOrganizationMembers(); + expect( + screen.queryByTestId("manage-organization-members-settings"), + ).not.toBeInTheDocument(); + }); + + it("should allow the user to select an organization", async () => { + const getOrganizationMembersSpy = vi.spyOn( + organizationService, + "getOrganizationMembers", + ); + + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + + // First org is auto-selected, so members are fetched for org "1" + await selectOrganization({ orgIndex: 1 }); // Acme Corp + expect(getOrganizationMembersSpy).toHaveBeenLastCalledWith({ + orgId: "2", + page: 1, + limit: 10, + email: undefined, + }); + }); + + it("should render the list of organization members", async () => { + await setupTestWithOrg(0); + const members = ORGS_AND_MEMBERS["1"]; + + // Wait for org "1" member to appear (ensures org switch is complete) + // This is needed because placeholderData: keepPreviousData shows stale data during transitions + await screen.findByText(members[0].email); + + const memberListItems = await screen.findAllByTestId("member-item"); + expect(memberListItems).toHaveLength(members.length); + + members.forEach((member) => { + expect(screen.getByText(member.email)).toBeInTheDocument(); + expect(screen.getByText(member.role)).toBeInTheDocument(); + }); + }); + + test("an admin should be able to change the role of a organization member", async () => { + await setupTestWithUserAndOrg( + { + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + 1, // Acme Corp (org "2") - has owner, admin, user + ); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + const memberListItems = await screen.findAllByTestId("member-item"); + const userRoleMember = memberListItems[2]; // third member is "user" (charlie) + + let userCombobox = within(userRoleMember).getByText(/^Member$/i); + expect(userCombobox).toBeInTheDocument(); + + // Change role from user to admin + await changeMemberRole(userRoleMember, "member", "admin"); + + expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith({ + userId: "3", // charlie's id + orgId: "2", + role: "admin", + }); + expectDropdownNotVisible(userRoleMember); + + // Verify the role has been updated in the UI + userCombobox = within(userRoleMember).getByText(/^Admin$/i); + expect(userCombobox).toBeInTheDocument(); + + // Revert the role back to user + await changeMemberRole(userRoleMember, "admin", "member"); + + expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(2, { + userId: "3", + orgId: "2", + role: "member", + }); + + // Verify the role has been reverted in the UI + userCombobox = within(userRoleMember).getByText(/^Member$/i); + expect(userCombobox).toBeInTheDocument(); + }); + + it("should not allow an admin to change the owner's role", async () => { + // User is bob (admin, user_id: "2") trying to edit alice (owner, user_id: "1") + // Admins don't have change_user_role:owner permission, so dropdown shouldn't show + + // Reset mock data to ensure clean state + resetOrgsAndMembersMockData(); + + // Pre-seed the /me query data to avoid stale cache issues + const userData = { + org_id: "2", + user_id: "2", // bob (admin) - different from alice + email: "bob@acme.org", + role: "admin" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }; + + getMeSpy.mockResolvedValue(userData); + queryClient.setQueryData(["organizations", "2", "me"], userData); + + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex: 1 }); // Acme Corp (org "2") + + // Wait for member list to load + const memberListItems = await screen.findAllByTestId("member-item"); + + // First member is alice (owner) + const targetMember = memberListItems[0]; + const roleText = within(targetMember).getByText(/^owner$/i); + expect(roleText).toBeInTheDocument(); + await userEvent.click(roleText); + + // Verify that the dropdown does not open (admin can't edit owner) + expectDropdownNotVisible(targetMember); + }); + + it("should allow an admin to change another admin's role", async () => { + // Mock members to include two admins so we can test admin editing another admin + const getOrganizationMembersSpy = vi.spyOn( + organizationService, + "getOrganizationMembers", + ); + const getOrganizationMembersCountSpy = vi.spyOn( + organizationService, + "getOrganizationMembersCount", + ); + + const twoAdminsMembers = [ + { + org_id: "2", + user_id: "1", + email: "admin1@acme.org", + role: "admin" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + { + org_id: "2", + user_id: "2", + email: "admin2@acme.org", + role: "admin" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + ]; + + getOrganizationMembersSpy.mockResolvedValue({ + items: twoAdminsMembers, + current_page: 1, + per_page: 10, + }); + getOrganizationMembersCountSpy.mockResolvedValue(2); + + // Current user is admin1 (user_id: "1") + getMeSpy.mockResolvedValue({ + org_id: "2", + user_id: "1", + email: "admin1@acme.org", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex: 1 }); // Acme Corp + + // Find admin2 and change their role to member + const admin2Member = await findMemberByEmail("admin2@acme.org"); + await changeMemberRole(admin2Member, "admin", "member"); + + expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith({ + userId: "2", + orgId: "2", + role: "member", + }); + + // Restore spies to prevent interference with subsequent tests + getOrganizationMembersSpy.mockRestore(); + getOrganizationMembersCountSpy.mockRestore(); + }); + + it("should not allow a user to change their own role", async () => { + // Mock the /me endpoint to return a user ID that matches one of the members + await verifyRoleChangeNotPermitted( + { + org_id: "1", + user_id: "1", // Same as first member from org 1 + email: "alice@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + 0, + 0, // First member (user_id: "1") + "Owner", + ); + }); + + it("should show a remove option in the role dropdown and remove the user from the list", async () => { + const removeMemberSpy = vi.spyOn(organizationService, "removeMember"); + + await setupTestWithOrg(1); // Acme Corp (org "2") - has owner, admin, user + + // Get initial member count + const memberListItems = await screen.findAllByTestId("member-item"); + const initialMemberCount = memberListItems.length; + + const userRoleMember = memberListItems[2]; // third member is "user" + const userEmail = within(userRoleMember).getByText("charlie@acme.org"); + expect(userEmail).toBeInTheDocument(); + + const userCombobox = within(userRoleMember).getByText(/^Member$/i); + await userEvent.click(userCombobox); + + const dropdown = within(userRoleMember).getByTestId( + "organization-member-role-context-menu", + ); + + // Check that remove option exists + const removeOption = within(dropdown).getByTestId("remove-option"); + expect(removeOption).toBeInTheDocument(); + + await userEvent.click(removeOption); + + // Wait for confirmation modal to appear and click confirm + const confirmButton = await screen.findByTestId("confirm-button"); + await userEvent.click(confirmButton); + + expect(removeMemberSpy).toHaveBeenCalledExactlyOnceWith({ + orgId: "2", + userId: "3", + }); + + // Verify the user is no longer in the list + await waitFor(() => { + const updatedMemberListItems = screen.getAllByTestId("member-item"); + expect(updatedMemberListItems).toHaveLength(initialMemberCount - 1); + }); + + // Verify the specific user email is no longer present + expect(screen.queryByText("charlie@acme.org")).not.toBeInTheDocument(); + }); + + + describe("Inviting Organization Members", () => { + it("should render an invite organization member button", async () => { + await setupInviteTest(); + + const inviteButton = await findInviteButton(); + expect(inviteButton).toBeInTheDocument(); + }); + + it("should render a modal when the invite button is clicked", async () => { + await setupInviteTest(); + + expect(screen.queryByTestId("invite-modal")).not.toBeInTheDocument(); + const inviteButton = await findInviteButton(); + await userEvent.click(inviteButton); + + const portalRoot = screen.getByTestId("portal-root"); + expect( + within(portalRoot).getByTestId("invite-modal"), + ).toBeInTheDocument(); + }); + + it("should close the modal when the close button is clicked", async () => { + await setupInviteTest(); + + const inviteButton = await findInviteButton(); + await userEvent.click(inviteButton); + + const modal = screen.getByTestId("invite-modal"); + const closeButton = within(modal).getByText("BUTTON$CLOSE"); + await userEvent.click(closeButton); + + expect(screen.queryByTestId("invite-modal")).not.toBeInTheDocument(); + }); + + it("should render a list item in an invited state when a the user is is invited", async () => { + const getOrganizationMembersSpy = vi.spyOn( + organizationService, + "getOrganizationMembers", + ); + const getOrganizationMembersCountSpy = vi.spyOn( + organizationService, + "getOrganizationMembersCount", + ); + + getOrganizationMembersSpy.mockResolvedValue({ + items: [ + { + org_id: "1", + user_id: "4", + email: "tom@acme.org", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "invited", + }, + ], + current_page: 1, + per_page: 10, + }); + getOrganizationMembersCountSpy.mockResolvedValue(1); + + await setupInviteTest(); + + const members = await screen.findAllByTestId("member-item"); + expect(members).toHaveLength(1); + + const invitedMember = members[0]; + expect(invitedMember).toBeInTheDocument(); + + // should have an "invited" badge + const invitedBadge = within(invitedMember).getByText(/invited/i); + expect(invitedBadge).toBeInTheDocument(); + + // should not have a role combobox + await userEvent.click(within(invitedMember).getByText(/^Member$/i)); + expect( + within(invitedMember).queryByTestId( + "organization-member-role-context-menu", + ), + ).not.toBeInTheDocument(); + }); + }); + + describe("Role-based invite permission behavior", () => { + it.each([ + { role: "owner" as const, roleName: "Owner" }, + { role: "admin" as const, roleName: "Admin" }, + ])( + "should show invite button when user has canInviteUsers permission ($roleName role)", + async ({ role }) => { + getMeSpy.mockResolvedValue({ + org_id: "1", + user_id: "1", + email: "test@example.com", + role, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + await setupTestWithOrg(0); + + const inviteButton = await findInviteButton(); + + expect(inviteButton).toBeInTheDocument(); + expect(inviteButton).not.toBeDisabled(); + }, + ); + + it("should not show invite button when user lacks canInviteUsers permission (User role)", async () => { + const userData = { + org_id: "1", + user_id: "1", + email: "test@example.com", + role: "member" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }; + + // Set mock and remove cached query before rendering + getMeSpy.mockResolvedValue(userData); + // Remove any cached "me" queries so fresh data is fetched + queryClient.removeQueries({ queryKey: ["organizations"] }); + + await setupTestWithOrg(0); + + // Directly set the query data to force component re-render with user role + // This ensures the component uses the user role data instead of cached admin data + queryClient.setQueryData(["organizations", "1", "me"], userData); + + // Wait for the component to update with the new query data + await waitFor( + () => { + const inviteButton = screen.queryByRole("button", { + name: /ORG\$INVITE_ORG_MEMBERS/i, + }); + expect(inviteButton).not.toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + }); + }); + + describe("Role-based role change permission behavior", () => { + it("should not allow an owner to change their own role", async () => { + // Acme Corp (org "2") - alice is owner, can't change her own role + + // Reset mock data to ensure clean state + resetOrgsAndMembersMockData(); + + // Pre-seed the /me query data to avoid stale cache issues + const userData = { + org_id: "2", + user_id: "1", // alice (owner) - same as first member + email: "alice@acme.org", + role: "owner" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }; + + getMeSpy.mockResolvedValue(userData); + queryClient.setQueryData(["organizations", "2", "me"], userData); + + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex: 1 }); // Acme Corp (org "2") + + // Wait for member list to load + const memberListItems = await screen.findAllByTestId("member-item"); + + // First member is alice (owner) - same as current user + const targetMember = memberListItems[0]; + const roleText = within(targetMember).getByText(/^owner$/i); + expect(roleText).toBeInTheDocument(); + await userEvent.click(roleText); + + // Verify that the dropdown does not open (can't edit own role) + expectDropdownNotVisible(targetMember); + }); + + it("should allow an owner to change another owner's role", async () => { + // Mock members to include two owners so we can test owner editing another owner + const getOrganizationMembersSpy = vi.spyOn( + organizationService, + "getOrganizationMembers", + ); + const getOrganizationMembersCountSpy = vi.spyOn( + organizationService, + "getOrganizationMembersCount", + ); + + const twoOwnersMembers = [ + { + org_id: "2", + user_id: "1", + email: "owner1@acme.org", + role: "owner" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + { + org_id: "2", + user_id: "2", + email: "owner2@acme.org", + role: "owner" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + ]; + + getOrganizationMembersSpy.mockResolvedValue({ + items: twoOwnersMembers, + current_page: 1, + per_page: 10, + }); + getOrganizationMembersCountSpy.mockResolvedValue(2); + + // Current user is owner1 (user_id: "1") + getMeSpy.mockResolvedValue({ + org_id: "2", + user_id: "1", + email: "owner1@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + renderManageOrganizationMembers(); + await screen.findByTestId("manage-organization-members-settings"); + await selectOrganization({ orgIndex: 1 }); // Acme Corp + + // Find owner2 and change their role to admin + const owner2Member = await findMemberByEmail("owner2@acme.org"); + await changeMemberRole(owner2Member, "owner", "admin"); + + expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith({ + userId: "2", + orgId: "2", + role: "admin", + }); + + // Restore spies to prevent interference with subsequent tests + getOrganizationMembersSpy.mockRestore(); + getOrganizationMembersCountSpy.mockRestore(); + }); + + it("Owner should see all three role options (owner, admin, user) in dropdown regardless of target member's role", async () => { + await setupTestWithUserAndOrg( + { + org_id: "1", + user_id: "1", // First member is owner in org 1 + email: "alice@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + 1, // Acme Corp (org "2") + ); + + const memberListItems = await screen.findAllByTestId("member-item"); + + // Test with admin member + const adminMember = memberListItems[1]; // Second member is admin (user_id: "2") + const adminDropdown = await openRoleDropdown(adminMember, "admin"); + + // Verify all three role options are present for admin member + expectAllRoleOptionsPresent(adminDropdown); + + // Close dropdown by clicking outside + await closeDropdown(); + + // Test with user member + const userMember = await findMemberByEmail("charlie@acme.org"); + const userDropdown = await openRoleDropdown(userMember, "member"); + + // Verify all three role options are present for user member + expectAllRoleOptionsPresent(userDropdown); + }); + + it("Admin should not see owner option in role dropdown for any member", async () => { + await setupTestWithUserAndOrg( + { + org_id: "3", + user_id: "7", // Ray is admin in org 3 + email: "ray@all-hands.dev", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + 3, // All Hands AI (org "4") + ); + + const memberListItems = await screen.findAllByTestId("member-item"); + + // Check user member dropdown + const userMember = memberListItems[2]; // user member + const userDropdown = await openRoleDropdown(userMember, "member"); + expectOwnerOptionNotPresent(userDropdown); + await closeDropdown(); + + // Check another user member dropdown (stephan is at index 3) + if (memberListItems.length > 3) { + const anotherUserMember = memberListItems[3]; // stephan@all-hands.dev + const anotherUserDropdown = await openRoleDropdown( + anotherUserMember, + "member", + ); + expectOwnerOptionNotPresent(anotherUserDropdown); + } + }); + + it("Owner should be able to change any member's role to owner", async () => { + await setupTestWithUserAndOrg( + { + org_id: "1", + user_id: "1", // First member is owner in org 1 + email: "alice@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + 1, // Acme Corp (org "2") + ); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + const memberListItems = await screen.findAllByTestId("member-item"); + + // Test changing admin to owner + const adminMember = memberListItems[1]; // Second member is admin (user_id: "2") + await changeMemberRole(adminMember, "admin", "owner"); + + expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(1, { + userId: "2", + orgId: "2", + role: "owner", + }); + + // Test changing user to owner + const userMember = await findMemberByEmail("charlie@acme.org"); + await changeMemberRole(userMember, "member", "owner"); + + expect(updateMemberRoleSpy).toHaveBeenNthCalledWith(2, { + userId: "3", + orgId: "2", + role: "owner", + }); + }); + + it("Admin should be able to change member's role to admin", async () => { + await setupTestWithUserAndOrg( + { + org_id: "4", + user_id: "7", // Ray is admin in org 4 + email: "ray@all-hands.dev", + role: "admin" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + 3, // All Hands AI (org "4") + ); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + const member = await findMemberByEmail("stephan@all-hands.dev"); + + await changeMemberRole(member, "member", "admin"); + + expect(updateMemberRoleSpy).toHaveBeenCalledExactlyOnceWith({ + userId: "9", + orgId: "4", + role: "admin" as const, + }); + }); + + it("should not show confirmation modal or call API when selecting the same role", async () => { + await setupTestWithUserAndOrg( + { + org_id: "1", + user_id: "1", // First member is owner in org 1 + email: "alice@acme.org", + role: "owner" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active" as const, + }, + 1, // Acme Corp (org "2") + ); + + const updateMemberRoleSpy = createUpdateMemberRoleSpy(); + + const member = await findMemberByEmail("bob@acme.org"); + + // Open dropdown and select the same role (admin -> admin) + const dropdown = await openRoleDropdown(member, "admin"); + const roleOption = within(dropdown).getByText(/admin/i); + await userEvent.click(roleOption); + + // Verify no confirmation modal appears + expect(screen.queryByTestId("confirm-button")).not.toBeInTheDocument(); + + // Verify no API call was made + expect(updateMemberRoleSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/__tests__/routes/mcp-settings.test.tsx b/frontend/__tests__/routes/mcp-settings.test.tsx new file mode 100644 index 0000000000..955c290e67 --- /dev/null +++ b/frontend/__tests__/routes/mcp-settings.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { clientLoader } from "#/routes/mcp-settings"; + +describe("clientLoader permission checks", () => { + it("should export a clientLoader for route protection", () => { + // This test verifies the clientLoader is exported (for consistency with other routes) + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); +}); diff --git a/frontend/__tests__/routes/secrets-settings.test.tsx b/frontend/__tests__/routes/secrets-settings.test.tsx index 2062117f8b..f67d15f42e 100644 --- a/frontend/__tests__/routes/secrets-settings.test.tsx +++ b/frontend/__tests__/routes/secrets-settings.test.tsx @@ -3,12 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import { createRoutesStub, Outlet } from "react-router"; -import SecretsSettingsScreen from "#/routes/secrets-settings"; +import SecretsSettingsScreen, { clientLoader } from "#/routes/secrets-settings"; import { SecretsService } from "#/api/secrets-service"; import { GetSecretsResponse } from "#/api/secrets-service.types"; import SettingsService from "#/api/settings-service/settings-service.api"; import OptionService from "#/api/option-service/option-service.api"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; +import { OrganizationMember } from "#/types/org"; +import * as orgStore from "#/stores/selected-organization-store"; +import { organizationService } from "#/api/organization-service/organization-service.api"; const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [ { @@ -66,6 +69,75 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe("clientLoader permission checks", () => { + const createMockUser = ( + overrides: Partial = {}, + ): OrganizationMember => ({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + ...overrides, + }); + + const seedActiveUser = (user: Partial) => { + orgStore.useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser(user), + ); + }; + + it("should export a clientLoader for route protection", () => { + // This test verifies the clientLoader is exported (for consistency with other routes) + expect(clientLoader).toBeDefined(); + expect(typeof clientLoader).toBe("function"); + }); + + it("should allow members to access secrets settings (all roles have manage_secrets)", async () => { + // Arrange + seedActiveUser({ role: "member" }); + + const RouterStub = createRoutesStub([ + { + Component: SecretsSettingsScreen, + loader: clientLoader, + path: "/settings/secrets", + }, + { + Component: () =>
, + path: "/settings/user", + }, + ]); + + // Act + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Assert - should stay on secrets settings page (not redirected) + await waitFor(() => { + expect(screen.getByTestId("secrets-settings-screen")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("user-settings-screen")).not.toBeInTheDocument(); + }); +}); + describe("Content", () => { it("should render the secrets settings screen", () => { renderSecretsSettings(); diff --git a/frontend/__tests__/routes/settings-with-payment.test.tsx b/frontend/__tests__/routes/settings-with-payment.test.tsx index 0131b86d43..1a682c96b7 100644 --- a/frontend/__tests__/routes/settings-with-payment.test.tsx +++ b/frontend/__tests__/routes/settings-with-payment.test.tsx @@ -1,22 +1,22 @@ -import { screen, within } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRoutesStub } from "react-router"; -import { renderWithProviders } from "test-utils"; import SettingsScreen from "#/routes/settings"; import { PaymentForm } from "#/components/features/payment/payment-form"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +let queryClient: QueryClient; // Mock the useSettings hook vi.mock("#/hooks/query/use-settings", async () => { - const actual = await vi.importActual< - typeof import("#/hooks/query/use-settings") - >("#/hooks/query/use-settings"); + const actual = await vi.importActual( + "#/hooks/query/use-settings" + ); return { ...actual, useSettings: vi.fn().mockReturnValue({ - data: { - EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection - }, + data: { EMAIL_VERIFIED: true }, isLoading: false, }), }; @@ -52,21 +52,36 @@ vi.mock("react-i18next", async () => { }); // Mock useConfig hook -const { mockUseConfig } = vi.hoisted(() => ({ +const { mockUseConfig, mockUseMe, mockUsePermission } = vi.hoisted(() => ({ mockUseConfig: vi.fn(), + mockUseMe: vi.fn(), + mockUsePermission: vi.fn(), })); + vi.mock("#/hooks/query/use-config", () => ({ useConfig: mockUseConfig, })); +vi.mock("#/hooks/query/use-me", () => ({ + useMe: mockUseMe, +})); + +vi.mock("#/hooks/organizations/use-permissions", () => ({ + usePermission: () => ({ + hasPermission: mockUsePermission, + }), +})); + describe("Settings Billing", () => { beforeEach(() => { - // Set default config to OSS mode + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + // Set default config to OSS mode with lowercase keys mockUseConfig.mockReturnValue({ data: { app_mode: "oss", - github_client_id: "123", - posthog_client_key: "456", feature_flags: { enable_billing: false, hide_llm_settings: false, @@ -80,6 +95,13 @@ describe("Settings Billing", () => { }, isLoading: false, }); + + mockUseMe.mockReturnValue({ + data: { role: "admin" }, + isLoading: false, + }); + + mockUsePermission.mockReturnValue(false); // default: no billing access }); const RoutesStub = createRoutesStub([ @@ -104,14 +126,38 @@ describe("Settings Billing", () => { ]); const renderSettingsScreen = () => - renderWithProviders(); + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); - afterEach(() => { - vi.clearAllMocks(); - }); + afterEach(() => vi.clearAllMocks()); it("should not render the billing tab if OSS mode", async () => { - // OSS mode is set by default in beforeEach + mockUseConfig.mockReturnValue({ + data: { + app_mode: "oss", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + }, + }, + isLoading: false, + }); + + mockUseMe.mockReturnValue({ + data: { role: "admin" }, + isLoading: false, + }); + + mockUsePermission.mockReturnValue(true); + renderSettingsScreen(); const navbar = await screen.findByTestId("settings-navbar"); @@ -119,12 +165,10 @@ describe("Settings Billing", () => { expect(credits).not.toBeInTheDocument(); }); - it("should render the billing tab if SaaS mode and billing is enabled", async () => { + it("should render the billing tab if: SaaS mode, billing enabled, admin user", async () => { mockUseConfig.mockReturnValue({ data: { app_mode: "saas", - github_client_id: "123", - posthog_client_key: "456", feature_flags: { enable_billing: true, hide_llm_settings: false, @@ -139,19 +183,23 @@ describe("Settings Billing", () => { isLoading: false, }); + mockUseMe.mockReturnValue({ + data: { role: "admin" }, + isLoading: false, + }); + + mockUsePermission.mockReturnValue(true); + renderSettingsScreen(); const navbar = await screen.findByTestId("settings-navbar"); - within(navbar).getByText("Billing"); + expect(within(navbar).getByText("Billing")).toBeInTheDocument(); }); - it("should render the billing settings if clicking the billing item", async () => { - const user = userEvent.setup(); + it("should NOT render the billing tab if: SaaS mode, billing is enabled, and member user", async () => { mockUseConfig.mockReturnValue({ data: { app_mode: "saas", - github_client_id: "123", - posthog_client_key: "456", feature_flags: { enable_billing: true, hide_llm_settings: false, @@ -166,6 +214,43 @@ describe("Settings Billing", () => { isLoading: false, }); + mockUseMe.mockReturnValue({ + data: { role: "member" }, + isLoading: false, + }); + + mockUsePermission.mockReturnValue(false); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + expect(within(navbar).queryByText("Billing")).not.toBeInTheDocument(); + }); + + it("should render the billing settings if clicking the billing item", async () => { + const user = userEvent.setup(); + // When enable_billing is true, the billing nav item is shown + mockUseConfig.mockReturnValue({ + data: { + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + }, + }, + isLoading: false, + }); + + mockUseMe.mockReturnValue({ + data: { role: "admin" }, + isLoading: false, + }); + + mockUsePermission.mockReturnValue(true); + renderSettingsScreen(); const navbar = await screen.findByTestId("settings-navbar"); diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx index c39f389c3c..bdc2c0c822 100644 --- a/frontend/__tests__/routes/settings.test.tsx +++ b/frontend/__tests__/routes/settings.test.tsx @@ -1,13 +1,16 @@ -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import { createRoutesStub } from "react-router"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { QueryClientProvider } from "@tanstack/react-query"; -import SettingsScreen, { - clientLoader, - getFirstAvailablePath, -} from "#/routes/settings"; +import SettingsScreen, { clientLoader } from "#/routes/settings"; +import { getFirstAvailablePath } from "#/utils/settings-utils"; import OptionService from "#/api/option-service/option-service.api"; +import { OrganizationMember } from "#/types/org"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; import { WebClientFeatureFlags } from "#/api/option-service/option.types"; +import { createMockWebClientConfig } from "#/mocks/settings-handlers"; // Module-level mocks using vi.hoisted const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({ @@ -57,17 +60,44 @@ vi.mock("react-i18next", async () => { }); describe("Settings Screen", () => { + const createMockUser = ( + overrides: Partial = {}, + ): OrganizationMember => ({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + ...overrides, + }); + + const seedActiveUser = (user: Partial) => { + useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser(user), + ); + }; + const RouterStub = createRoutesStub([ { Component: SettingsScreen, // @ts-expect-error - custom loader - clientLoader, + loader: clientLoader, path: "/settings", children: [ { Component: () =>
, path: "/settings", }, + { + Component: () =>
, + path: "/settings/user", + }, { Component: () =>
, path: "/settings/integrations", @@ -84,6 +114,15 @@ describe("Settings Screen", () => { Component: () =>
, path: "/settings/api-keys", }, + { + Component: () =>
, + path: "/settings/org-members", + handle: { hideTitle: true }, + }, + { + Component: () =>
, + path: "/settings/org", + }, ], }, ]); @@ -129,11 +168,21 @@ describe("Settings Screen", () => { }); it("should render the saas navbar", async () => { - const saasConfig = { app_mode: "saas" }; + const saasConfig = { + app_mode: "saas", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + }, + }; // Clear any existing query data and set the config mockQueryClient.clear(); mockQueryClient.setQueryData(["web-client-config"], saasConfig); + seedActiveUser({ role: "admin" }); const sectionsToInclude = [ "llm", // LLM settings are now always shown in SaaS mode @@ -149,6 +198,9 @@ describe("Settings Screen", () => { renderSettingsScreen(); const navbar = await screen.findByTestId("settings-navbar"); + await waitFor(() => { + expect(within(navbar).getByText("Billing")).toBeInTheDocument(); + }); sectionsToInclude.forEach((section) => { const sectionElement = within(navbar).getByText(section, { exact: false, // case insensitive @@ -200,12 +252,367 @@ describe("Settings Screen", () => { it.todo("should not be able to access oss-only routes in saas mode"); + describe("Personal org vs team org visibility", () => { + it("should not show Organization and Organization Members settings items when personal org is selected", async () => { + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + + // Organization and Organization Members should NOT be visible for personal org + expect( + within(navbar).queryByText("Organization Members"), + ).not.toBeInTheDocument(); + expect( + within(navbar).queryByText("Organization"), + ).not.toBeInTheDocument(); + }); + + it("should not show Billing settings item when team org is selected", async () => { + // Set up SaaS mode (which has Billing in nav items) + mockQueryClient.clear(); + mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" }); + // Pre-select the team org in the query client and Zustand store + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "2" }); + + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "2", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderSettingsScreen(); + + const navbar = await screen.findByTestId("settings-navbar"); + + // Wait for orgs to load, then verify Billing is hidden for team orgs + await waitFor(() => { + expect( + within(navbar).queryByText("Billing", { exact: false }), + ).not.toBeInTheDocument(); + }); + }); + + it("should not allow direct URL access to /settings/org when personal org is selected", async () => { + // Set up orgs in query client so clientLoader can access them + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + // Use Zustand store instead of query client for selected org ID + // This is the correct pattern - the query client key ["selected_organization"] is never set in production + useSelectedOrganizationStore.setState({ organizationId: "1" }); + + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderSettingsScreen("/settings/org"); + + // Should redirect away from org settings for personal org + await waitFor(() => { + expect( + screen.queryByTestId("organization-settings-screen"), + ).not.toBeInTheDocument(); + }); + }); + + it("should not allow direct URL access to /settings/org-members when personal org is selected", async () => { + // Set up config and organizations in query client so clientLoader can access them + mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" }); + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + // Use Zustand store for selected org ID + useSelectedOrganizationStore.setState({ organizationId: "1" }); + + // Mock getMe so getActiveOrganizationUser returns admin + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser({ role: "admin", org_id: "1" }), + ); + + // Act: Call clientLoader directly with the REAL route path (as defined in routes.ts) + const request = new Request("http://localhost/settings/org-members"); + // @ts-expect-error - test only needs request and params, not full loader args + const result = await clientLoader({ request, params: {} }); + + // Assert: Should redirect away from org-members settings for personal org + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Response); + const response = result as Response; + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/settings"); + }); + + it("should not allow direct URL access to /settings/billing when team org is selected", async () => { + // Set up orgs in query client so clientLoader can access them + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + // Use Zustand store instead of query client for selected org ID + useSelectedOrganizationStore.setState({ organizationId: "2" }); + + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + renderSettingsScreen("/settings/billing"); + + // Should redirect away from billing settings for team org + await waitFor(() => { + expect( + screen.queryByTestId("billing-settings-screen"), + ).not.toBeInTheDocument(); + }); + }); + }); + + describe("enable_billing feature flag", () => { + it("should show billing navigation item when enable_billing is true", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: true, // When enable_billing is true, billing nav is shown + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + mockQueryClient.clear(); + // Set up personal org (billing is only shown for personal orgs, not team orgs) + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "1" }); + + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + + // Act + renderSettingsScreen(); + + // Assert + const navbar = await screen.findByTestId("settings-navbar"); + await waitFor(() => { + expect(within(navbar).getByText("Billing")).toBeInTheDocument(); + }); + + getConfigSpy.mockRestore(); + }); + + it("should hide billing navigation item when enable_billing is false", async () => { + // Arrange + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + getConfigSpy.mockResolvedValue( + createMockWebClientConfig({ + app_mode: "saas", + feature_flags: { + enable_billing: false, // When enable_billing is false, billing nav is hidden + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + }, + }), + ); + + mockQueryClient.clear(); + + // Act + renderSettingsScreen(); + + // Assert + const navbar = await screen.findByTestId("settings-navbar"); + expect(within(navbar).queryByText("Billing")).not.toBeInTheDocument(); + + getConfigSpy.mockRestore(); + }); + }); + + describe("clientLoader reads org ID from Zustand store", () => { + beforeEach(() => { + mockQueryClient.clear(); + useSelectedOrganizationStore.setState({ organizationId: null }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should redirect away from /settings/org when personal org is selected in Zustand store", async () => { + // Arrange: Set up config and organizations in query client + mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" }); + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + + // Set org ID ONLY in Zustand store (not in query client) + // This tests that clientLoader reads from the correct source + useSelectedOrganizationStore.setState({ organizationId: "1" }); + + // Mock getMe so getActiveOrganizationUser returns admin + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser({ role: "admin", org_id: "1" }), + ); + + // Act: Call clientLoader directly + const request = new Request("http://localhost/settings/org"); + // @ts-expect-error - test only needs request and params, not full loader args + const result = await clientLoader({ request, params: {} }); + + // Assert: Should redirect away from org settings for personal org + expect(result).not.toBeNull(); + // In React Router, redirect returns a Response object + expect(result).toBeInstanceOf(Response); + const response = result as Response; + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/settings"); + }); + + it("should redirect away from /settings/billing when team org is selected in Zustand store", async () => { + // Arrange: Set up config and organizations in query client + mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" }); + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_TEAM_ORG_ACME], + currentOrgId: MOCK_TEAM_ORG_ACME.id, + }); + + // Set org ID ONLY in Zustand store (not in query client) + useSelectedOrganizationStore.setState({ organizationId: "2" }); + + // Mock getMe so getActiveOrganizationUser returns admin + vi.spyOn(organizationService, "getMe").mockResolvedValue( + createMockUser({ role: "admin", org_id: "2" }), + ); + + // Act: Call clientLoader directly + const request = new Request("http://localhost/settings/billing"); + // @ts-expect-error - test only needs request and params, not full loader args + const result = await clientLoader({ request, params: {} }); + + // Assert: Should redirect away from billing settings for team org + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Response); + const response = result as Response; + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/settings/user"); + }); + }); + describe("hide page feature flags", () => { + beforeEach(() => { + // Set up as personal org admin so billing is accessible + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "1" }); + vi.spyOn(organizationService, "getMe").mockResolvedValue({ + org_id: "1", + user_id: "99", + email: "me@test.com", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }); + }); + it("should hide users page in navbar when hide_users_page is true", async () => { const saasConfig = { app_mode: "saas", feature_flags: { - enable_billing: false, + enable_billing: true, // Enable billing so it's not hidden by isBillingHidden hide_llm_settings: false, enable_jira: false, enable_jira_dc: false, @@ -218,6 +625,14 @@ describe("Settings Screen", () => { mockQueryClient.clear(); mockQueryClient.setQueryData(["web-client-config"], saasConfig); + // Set up personal org so billing is visible + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "1" }); + // Pre-populate user data in cache so useMe() returns admin role immediately + mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" })); renderSettingsScreen(); @@ -238,7 +653,7 @@ describe("Settings Screen", () => { const saasConfig = { app_mode: "saas", feature_flags: { - enable_billing: false, + enable_billing: true, hide_llm_settings: false, enable_jira: false, enable_jira_dc: false, @@ -251,6 +666,11 @@ describe("Settings Screen", () => { mockQueryClient.clear(); mockQueryClient.setQueryData(["web-client-config"], saasConfig); + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "1" }); renderSettingsScreen(); @@ -271,7 +691,7 @@ describe("Settings Screen", () => { const saasConfig = { app_mode: "saas", feature_flags: { - enable_billing: false, + enable_billing: true, hide_llm_settings: false, enable_jira: false, enable_jira_dc: false, @@ -284,6 +704,13 @@ describe("Settings Screen", () => { mockQueryClient.clear(); mockQueryClient.setQueryData(["web-client-config"], saasConfig); + mockQueryClient.setQueryData(["organizations"], { + items: [MOCK_PERSONAL_ORG], + currentOrgId: MOCK_PERSONAL_ORG.id, + }); + useSelectedOrganizationStore.setState({ organizationId: "1" }); + // Pre-populate user data in cache so useMe() returns admin role immediately + mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" })); renderSettingsScreen(); diff --git a/frontend/__tests__/stores/selected-organization-store.test.ts b/frontend/__tests__/stores/selected-organization-store.test.ts new file mode 100644 index 0000000000..d3abd6d975 --- /dev/null +++ b/frontend/__tests__/stores/selected-organization-store.test.ts @@ -0,0 +1,51 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; + +describe("useSelectedOrganizationStore", () => { + it("should have null as initial organizationId", () => { + const { result } = renderHook(() => useSelectedOrganizationStore()); + expect(result.current.organizationId).toBeNull(); + }); + + it("should update organizationId when setOrganizationId is called", () => { + const { result } = renderHook(() => useSelectedOrganizationStore()); + + act(() => { + result.current.setOrganizationId("org-123"); + }); + + expect(result.current.organizationId).toBe("org-123"); + }); + + it("should allow setting organizationId to null", () => { + const { result } = renderHook(() => useSelectedOrganizationStore()); + + act(() => { + result.current.setOrganizationId("org-123"); + }); + + expect(result.current.organizationId).toBe("org-123"); + + act(() => { + result.current.setOrganizationId(null); + }); + + expect(result.current.organizationId).toBeNull(); + }); + + it("should share state across multiple hook instances", () => { + const { result: result1 } = renderHook(() => + useSelectedOrganizationStore(), + ); + const { result: result2 } = renderHook(() => + useSelectedOrganizationStore(), + ); + + act(() => { + result1.current.setOrganizationId("shared-organization"); + }); + + expect(result2.current.organizationId).toBe("shared-organization"); + }); +}); diff --git a/frontend/__tests__/utils/billing-visibility.test.ts b/frontend/__tests__/utils/billing-visibility.test.ts new file mode 100644 index 0000000000..2feb63f52d --- /dev/null +++ b/frontend/__tests__/utils/billing-visibility.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { isBillingHidden } from "#/utils/org/billing-visibility"; +import { WebClientConfig } from "#/api/option-service/option.types"; + +describe("isBillingHidden", () => { + const createConfig = ( + featureFlagOverrides: Partial = {}, + ): WebClientConfig => + ({ + app_mode: "saas", + posthog_client_key: "test", + feature_flags: { + enable_billing: true, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + ...featureFlagOverrides, + }, + }) as WebClientConfig; + + it("should return true when config is undefined (safe default)", () => { + expect(isBillingHidden(undefined, true)).toBe(true); + }); + + it("should return true when enable_billing is false", () => { + const config = createConfig({ enable_billing: false }); + expect(isBillingHidden(config, true)).toBe(true); + }); + + it("should return true when user lacks view_billing permission", () => { + const config = createConfig(); + expect(isBillingHidden(config, false)).toBe(true); + }); + + it("should return true when both enable_billing is false and user lacks permission", () => { + const config = createConfig({ enable_billing: false }); + expect(isBillingHidden(config, false)).toBe(true); + }); + + it("should return false when enable_billing is true and user has view_billing permission", () => { + const config = createConfig(); + expect(isBillingHidden(config, true)).toBe(false); + }); + + it("should treat enable_billing as true by default (billing visible, subject to permission)", () => { + const config = createConfig({ enable_billing: true }); + expect(isBillingHidden(config, true)).toBe(false); + }); +}); diff --git a/frontend/__tests__/utils/input-validation.test.ts b/frontend/__tests__/utils/input-validation.test.ts new file mode 100644 index 0000000000..82f3aebe91 --- /dev/null +++ b/frontend/__tests__/utils/input-validation.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "vitest"; +import { + isValidEmail, + getInvalidEmails, + areAllEmailsValid, + hasDuplicates, +} from "#/utils/input-validation"; + +describe("isValidEmail", () => { + describe("valid email formats", () => { + test("accepts standard email formats", () => { + expect(isValidEmail("user@example.com")).toBe(true); + expect(isValidEmail("john.doe@company.org")).toBe(true); + expect(isValidEmail("test@subdomain.domain.com")).toBe(true); + }); + + test("accepts emails with numbers", () => { + expect(isValidEmail("user123@example.com")).toBe(true); + expect(isValidEmail("123user@example.com")).toBe(true); + expect(isValidEmail("user@example123.com")).toBe(true); + }); + + test("accepts emails with special characters in local part", () => { + expect(isValidEmail("user.name@example.com")).toBe(true); + expect(isValidEmail("user+tag@example.com")).toBe(true); + expect(isValidEmail("user_name@example.com")).toBe(true); + expect(isValidEmail("user-name@example.com")).toBe(true); + expect(isValidEmail("user%tag@example.com")).toBe(true); + }); + + test("accepts emails with various TLDs", () => { + expect(isValidEmail("user@example.io")).toBe(true); + expect(isValidEmail("user@example.co.uk")).toBe(true); + expect(isValidEmail("user@example.travel")).toBe(true); + }); + }); + + describe("invalid email formats", () => { + test("rejects empty strings", () => { + expect(isValidEmail("")).toBe(false); + }); + + test("rejects strings without @", () => { + expect(isValidEmail("userexample.com")).toBe(false); + expect(isValidEmail("user.example.com")).toBe(false); + }); + + test("rejects strings without domain", () => { + expect(isValidEmail("user@")).toBe(false); + expect(isValidEmail("user@.com")).toBe(false); + }); + + test("rejects strings without local part", () => { + expect(isValidEmail("@example.com")).toBe(false); + }); + + test("rejects strings without TLD", () => { + expect(isValidEmail("user@example")).toBe(false); + expect(isValidEmail("user@example.")).toBe(false); + }); + + test("rejects strings with single character TLD", () => { + expect(isValidEmail("user@example.c")).toBe(false); + }); + + test("rejects plain text", () => { + expect(isValidEmail("test")).toBe(false); + expect(isValidEmail("just some text")).toBe(false); + }); + + test("rejects emails with spaces", () => { + expect(isValidEmail("user @example.com")).toBe(false); + expect(isValidEmail("user@ example.com")).toBe(false); + expect(isValidEmail(" user@example.com")).toBe(false); + expect(isValidEmail("user@example.com ")).toBe(false); + }); + + test("rejects emails with multiple @ symbols", () => { + expect(isValidEmail("user@@example.com")).toBe(false); + expect(isValidEmail("user@domain@example.com")).toBe(false); + }); + }); +}); + +describe("getInvalidEmails", () => { + test("returns empty array when all emails are valid", () => { + const emails = ["user@example.com", "test@domain.org"]; + expect(getInvalidEmails(emails)).toEqual([]); + }); + + test("returns all invalid emails", () => { + const emails = ["valid@example.com", "invalid", "test@", "another@valid.org"]; + expect(getInvalidEmails(emails)).toEqual(["invalid", "test@"]); + }); + + test("returns all emails when none are valid", () => { + const emails = ["invalid", "also-invalid", "no-at-symbol"]; + expect(getInvalidEmails(emails)).toEqual(emails); + }); + + test("handles empty array", () => { + expect(getInvalidEmails([])).toEqual([]); + }); + + test("handles array with single invalid email", () => { + expect(getInvalidEmails(["invalid"])).toEqual(["invalid"]); + }); + + test("handles array with single valid email", () => { + expect(getInvalidEmails(["valid@example.com"])).toEqual([]); + }); +}); + +describe("areAllEmailsValid", () => { + test("returns true when all emails are valid", () => { + const emails = ["user@example.com", "test@domain.org", "admin@company.io"]; + expect(areAllEmailsValid(emails)).toBe(true); + }); + + test("returns false when any email is invalid", () => { + const emails = ["user@example.com", "invalid", "test@domain.org"]; + expect(areAllEmailsValid(emails)).toBe(false); + }); + + test("returns false when all emails are invalid", () => { + const emails = ["invalid", "also-invalid"]; + expect(areAllEmailsValid(emails)).toBe(false); + }); + + test("returns true for empty array", () => { + expect(areAllEmailsValid([])).toBe(true); + }); + + test("returns true for single valid email", () => { + expect(areAllEmailsValid(["valid@example.com"])).toBe(true); + }); + + test("returns false for single invalid email", () => { + expect(areAllEmailsValid(["invalid"])).toBe(false); + }); +}); + +describe("hasDuplicates", () => { + test("returns false when all values are unique", () => { + expect(hasDuplicates(["a@test.com", "b@test.com", "c@test.com"])).toBe( + false, + ); + }); + + test("returns true when duplicates exist", () => { + expect(hasDuplicates(["a@test.com", "b@test.com", "a@test.com"])).toBe(true); + }); + + test("returns true for case-insensitive duplicates", () => { + expect(hasDuplicates(["User@Test.com", "user@test.com"])).toBe(true); + expect(hasDuplicates(["A@EXAMPLE.COM", "a@example.com"])).toBe(true); + }); + + test("returns false for empty array", () => { + expect(hasDuplicates([])).toBe(false); + }); + + test("returns false for single item array", () => { + expect(hasDuplicates(["single@test.com"])).toBe(false); + }); + + test("handles multiple duplicates", () => { + expect( + hasDuplicates(["a@test.com", "a@test.com", "b@test.com", "b@test.com"]), + ).toBe(true); + }); +}); diff --git a/frontend/__tests__/utils/permission-checks.test.ts b/frontend/__tests__/utils/permission-checks.test.ts new file mode 100644 index 0000000000..d4730cb2a7 --- /dev/null +++ b/frontend/__tests__/utils/permission-checks.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { PermissionKey } from "#/utils/org/permissions"; + +// Mock dependencies for getActiveOrganizationUser tests +vi.mock("#/api/organization-service/organization-service.api", () => ({ + organizationService: { + getMe: vi.fn(), + }, +})); + +vi.mock("#/stores/selected-organization-store", () => ({ + getSelectedOrganizationIdFromStore: vi.fn(), +})); + +vi.mock("#/utils/query-client-getters", () => ({ + getMeFromQueryClient: vi.fn(), +})); + +vi.mock("#/query-client-config", () => ({ + queryClient: { + setQueryData: vi.fn(), + }, +})); + +// Import after mocks are set up +import { + getAvailableRolesAUserCanAssign, + getActiveOrganizationUser, +} from "#/utils/org/permission-checks"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store"; +import { getMeFromQueryClient } from "#/utils/query-client-getters"; + +describe("getAvailableRolesAUserCanAssign", () => { + it("returns empty array if user has no permissions", () => { + const result = getAvailableRolesAUserCanAssign([]); + expect(result).toEqual([]); + }); + + it("returns only roles the user has permission for", () => { + const userPermissions: PermissionKey[] = [ + "change_user_role:member", + "change_user_role:admin", + ]; + const result = getAvailableRolesAUserCanAssign(userPermissions); + expect(result.sort()).toEqual(["admin", "member"].sort()); + }); + + it("returns all roles if user has all permissions", () => { + const allPermissions: PermissionKey[] = [ + "change_user_role:member", + "change_user_role:admin", + "change_user_role:owner", + ]; + const result = getAvailableRolesAUserCanAssign(allPermissions); + expect(result.sort()).toEqual(["member", "admin", "owner"].sort()); + }); +}); + +describe("getActiveOrganizationUser", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return undefined when API call throws an error", async () => { + // Arrange: orgId exists, cache is empty, API call fails + vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1"); + vi.mocked(getMeFromQueryClient).mockReturnValue(undefined); + vi.mocked(organizationService.getMe).mockRejectedValue( + new Error("Network error"), + ); + + // Act + const result = await getActiveOrganizationUser(); + + // Assert: should return undefined instead of propagating the error + expect(result).toBeUndefined(); + }); +}); diff --git a/frontend/__tests__/utils/permission-guard.test.ts b/frontend/__tests__/utils/permission-guard.test.ts new file mode 100644 index 0000000000..5b1d19dd51 --- /dev/null +++ b/frontend/__tests__/utils/permission-guard.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { redirect } from "react-router"; + +// Mock dependencies before importing the module under test +vi.mock("react-router", () => ({ + redirect: vi.fn((path: string) => ({ type: "redirect", path })), +})); + +vi.mock("#/utils/org/permission-checks", () => ({ + getActiveOrganizationUser: vi.fn(), +})); + +vi.mock("#/api/option-service/option-service.api", () => ({ + default: { + getConfig: vi.fn().mockResolvedValue({ + app_mode: "saas", + feature_flags: { + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + hide_llm_settings: false, + }, + }), + }, +})); + +const mockConfig = { + app_mode: "saas", + feature_flags: { + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + hide_llm_settings: false, + }, +}; + +vi.mock("#/query-client-config", () => ({ + queryClient: { + getQueryData: vi.fn(() => mockConfig), + setQueryData: vi.fn(), + }, +})); + +// Import after mocks are set up +import { createPermissionGuard } from "#/utils/org/permission-guard"; +import { getActiveOrganizationUser } from "#/utils/org/permission-checks"; + +// Helper to create a mock request +const createMockRequest = (pathname: string = "/settings/billing") => ({ + request: new Request(`http://localhost${pathname}`), +}); + +describe("createPermissionGuard", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("permission checking", () => { + it("should redirect when user lacks required permission", async () => { + // Arrange: member lacks view_billing permission + vi.mocked(getActiveOrganizationUser).mockResolvedValue({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + }); + + // Act + const guard = createPermissionGuard("view_billing"); + await guard(createMockRequest("/settings/billing")); + + // Assert: should redirect to first available path (/settings/user in SaaS mode) + expect(redirect).toHaveBeenCalledWith("/settings/user"); + }); + + it("should allow access when user has required permission", async () => { + // Arrange: admin has view_billing permission + vi.mocked(getActiveOrganizationUser).mockResolvedValue({ + org_id: "org-1", + user_id: "user-1", + email: "admin@example.com", + role: "admin", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + }); + + // Act + const guard = createPermissionGuard("view_billing"); + const result = await guard(createMockRequest("/settings/billing")); + + // Assert: should not redirect, return null + expect(redirect).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it("should redirect when user is undefined (no org selected)", async () => { + // Arrange: no user (e.g., no organization selected) + vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined); + + // Act + const guard = createPermissionGuard("view_billing"); + await guard(createMockRequest("/settings/billing")); + + // Assert: should redirect to first available path + expect(redirect).toHaveBeenCalledWith("/settings/user"); + }); + + it("should redirect when user is undefined even for member-level permissions", async () => { + // Arrange: no user — manage_secrets is a member-level permission, + // but undefined user should NOT get member access + vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined); + + // Act + const guard = createPermissionGuard("manage_secrets"); + await guard(createMockRequest("/settings/secrets")); + + // Assert: should redirect, not silently grant member-level access + expect(redirect).toHaveBeenCalledWith("/settings/user"); + }); + }); + + describe("custom redirect path", () => { + it("should redirect to custom path when specified", async () => { + // Arrange: member lacks permission + vi.mocked(getActiveOrganizationUser).mockResolvedValue({ + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "member", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", + }); + + // Act + const guard = createPermissionGuard("view_billing", "/custom/redirect"); + await guard(createMockRequest("/settings/billing")); + + // Assert: should redirect to custom path + expect(redirect).toHaveBeenCalledWith("/custom/redirect"); + }); + }); + + describe("infinite loop prevention", () => { + it("should return null instead of redirecting when fallback path equals current path", async () => { + // Arrange: no user + vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined); + + // Act: access /settings/user when fallback would also be /settings/user + const guard = createPermissionGuard("view_billing"); + const result = await guard(createMockRequest("/settings/user")); + + // Assert: should NOT redirect to avoid infinite loop + expect(redirect).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); +}); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 4cd811ade2..0ca603e0ca 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -75,7 +75,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: "npm run dev:mock -- --port 3001", + command: "npm run dev:mock:saas -- --port 3001", url: "http://localhost:3001/", reuseExistingServer: !process.env.CI, }, diff --git a/frontend/src/api/organization-service/organization-service.api.ts b/frontend/src/api/organization-service/organization-service.api.ts new file mode 100644 index 0000000000..690adbac50 --- /dev/null +++ b/frontend/src/api/organization-service/organization-service.api.ts @@ -0,0 +1,159 @@ +import { + Organization, + OrganizationMember, + OrganizationMembersPage, + UpdateOrganizationMemberParams, +} from "#/types/org"; +import { openHands } from "../open-hands-axios"; + +export const organizationService = { + getMe: async ({ orgId }: { orgId: string }) => { + const { data } = await openHands.get( + `/api/organizations/${orgId}/me`, + ); + + return data; + }, + + getOrganization: async ({ orgId }: { orgId: string }) => { + const { data } = await openHands.get( + `/api/organizations/${orgId}`, + ); + return data; + }, + + getOrganizations: async () => { + const { data } = await openHands.get<{ + items: Organization[]; + current_org_id: string | null; + }>("/api/organizations"); + return { + items: data?.items || [], + currentOrgId: data?.current_org_id || null, + }; + }, + + updateOrganization: async ({ + orgId, + name, + }: { + orgId: string; + name: string; + }) => { + const { data } = await openHands.patch( + `/api/organizations/${orgId}`, + { name }, + ); + return data; + }, + + deleteOrganization: async ({ orgId }: { orgId: string }) => { + await openHands.delete(`/api/organizations/${orgId}`); + }, + + getOrganizationMembers: async ({ + orgId, + page = 1, + limit = 10, + email, + }: { + orgId: string; + page?: number; + limit?: number; + email?: string; + }) => { + const params = new URLSearchParams(); + + // Calculate offset from page number (page_id is offset-based) + const offset = (page - 1) * limit; + params.set("page_id", String(offset)); + params.set("limit", String(limit)); + + if (email) { + params.set("email", email); + } + + const { data } = await openHands.get( + `/api/organizations/${orgId}/members?${params.toString()}`, + ); + + return data; + }, + + getOrganizationMembersCount: async ({ + orgId, + email, + }: { + orgId: string; + email?: string; + }) => { + const params = new URLSearchParams(); + + if (email) { + params.set("email", email); + } + + const { data } = await openHands.get( + `/api/organizations/${orgId}/members/count?${params.toString()}`, + ); + + return data; + }, + + getOrganizationPaymentInfo: async ({ orgId }: { orgId: string }) => { + const { data } = await openHands.get<{ + cardNumber: string; + }>(`/api/organizations/${orgId}/payment`); + return data; + }, + + updateMember: async ({ + orgId, + userId, + ...updateData + }: { + orgId: string; + userId: string; + } & UpdateOrganizationMemberParams) => { + const { data } = await openHands.patch( + `/api/organizations/${orgId}/members/${userId}`, + updateData, + ); + + return data; + }, + + removeMember: async ({ + orgId, + userId, + }: { + orgId: string; + userId: string; + }) => { + await openHands.delete(`/api/organizations/${orgId}/members/${userId}`); + }, + + inviteMembers: async ({ + orgId, + emails, + }: { + orgId: string; + emails: string[]; + }) => { + const { data } = await openHands.post( + `/api/organizations/${orgId}/members/invite`, + { + emails, + }, + ); + + return data; + }, + + switchOrganization: async ({ orgId }: { orgId: string }) => { + const { data } = await openHands.post( + `/api/organizations/${orgId}/switch`, + ); + return data; + }, +}; diff --git a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx deleted file mode 100644 index cdc18521cb..0000000000 --- a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; -import { useFeatureFlagEnabled } from "posthog-js/react"; -import { ContextMenu } from "#/ui/context-menu"; -import { ContextMenuListItem } from "./context-menu-list-item"; -import { Divider } from "#/ui/divider"; -import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; -import { I18nKey } from "#/i18n/declaration"; -import LogOutIcon from "#/icons/log-out.svg?react"; -import DocumentIcon from "#/icons/document.svg?react"; -import PlusIcon from "#/icons/plus.svg?react"; -import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; -import { useConfig } from "#/hooks/query/use-config"; -import { useSettings } from "#/hooks/query/use-settings"; -import { useTracking } from "#/hooks/use-tracking"; - -interface AccountSettingsContextMenuProps { - onLogout: () => void; - onClose: () => void; -} - -export function AccountSettingsContextMenu({ - onLogout, - onClose, -}: AccountSettingsContextMenuProps) { - const ref = useClickOutsideElement(onClose); - const { t } = useTranslation(); - const { trackAddTeamMembersButtonClick } = useTracking(); - const { data: config } = useConfig(); - const { data: settings } = useSettings(); - const isAddTeamMemberEnabled = useFeatureFlagEnabled( - "exp_add_team_member_button", - ); - // Get navigation items and filter out LLM settings if the feature flag is enabled - const items = useSettingsNavItems(); - - const isSaasMode = config?.app_mode === "saas"; - const hasAnalyticsConsent = settings?.user_consents_to_analytics === true; - const showAddTeamMembers = - isSaasMode && isAddTeamMemberEnabled && hasAnalyticsConsent; - - const navItems = items.map((item) => ({ - ...item, - icon: React.cloneElement(item.icon, { - width: 16, - height: 16, - } as React.SVGProps), - })); - const handleNavigationClick = () => onClose(); - - const handleAddTeamMembers = () => { - trackAddTeamMembersButtonClick(); - onClose(); - }; - - return ( - - {showAddTeamMembers && ( - - - - {t(I18nKey.SETTINGS$NAV_ADD_TEAM_MEMBERS)} - - - )} - {navItems.map(({ to, text, icon }) => ( - - - {icon} - {t(text)} - - - ))} - - - - - - - {t(I18nKey.SIDEBAR$DOCS)} - - - - - - - {t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)} - - - - ); -} diff --git a/frontend/src/components/features/org/change-org-name-modal.tsx b/frontend/src/components/features/org/change-org-name-modal.tsx new file mode 100644 index 0000000000..56adb4e6c9 --- /dev/null +++ b/frontend/src/components/features/org/change-org-name-modal.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { OrgModal } from "#/components/shared/modals/org-modal"; +import { I18nKey } from "#/i18n/declaration"; +import { useUpdateOrganization } from "#/hooks/mutation/use-update-organization"; + +interface ChangeOrgNameModalProps { + onClose: () => void; +} + +export function ChangeOrgNameModal({ onClose }: ChangeOrgNameModalProps) { + const { t } = useTranslation(); + const { mutate: updateOrganization, isPending } = useUpdateOrganization(); + const [orgName, setOrgName] = useState(""); + + const handleSubmit = () => { + if (orgName?.trim()) { + updateOrganization(orgName, { + onSuccess: () => { + onClose(); + }, + }); + } + }; + + return ( + + setOrgName(e.target.value)} + className="bg-tertiary border border-[#717888] h-10 w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt" + /> + + ); +} diff --git a/frontend/src/components/features/org/confirm-remove-member-modal.tsx b/frontend/src/components/features/org/confirm-remove-member-modal.tsx new file mode 100644 index 0000000000..bfda8c7d32 --- /dev/null +++ b/frontend/src/components/features/org/confirm-remove-member-modal.tsx @@ -0,0 +1,42 @@ +import { Trans, useTranslation } from "react-i18next"; +import { OrgModal } from "#/components/shared/modals/org-modal"; +import { I18nKey } from "#/i18n/declaration"; + +interface ConfirmRemoveMemberModalProps { + onConfirm: () => void; + onCancel: () => void; + memberEmail: string; + isLoading?: boolean; +} + +export function ConfirmRemoveMemberModal({ + onConfirm, + onCancel, + memberEmail, + isLoading = false, +}: ConfirmRemoveMemberModalProps) { + const { t } = useTranslation(); + + const confirmationMessage = ( + }} + /> + ); + + return ( + + ); +} diff --git a/frontend/src/components/features/org/confirm-update-role-modal.tsx b/frontend/src/components/features/org/confirm-update-role-modal.tsx new file mode 100644 index 0000000000..f0b84affad --- /dev/null +++ b/frontend/src/components/features/org/confirm-update-role-modal.tsx @@ -0,0 +1,47 @@ +import { Trans, useTranslation } from "react-i18next"; +import { OrgModal } from "#/components/shared/modals/org-modal"; +import { I18nKey } from "#/i18n/declaration"; +import { OrganizationUserRole } from "#/types/org"; + +interface ConfirmUpdateRoleModalProps { + onConfirm: () => void; + onCancel: () => void; + memberEmail: string; + newRole: OrganizationUserRole; + isLoading?: boolean; +} + +export function ConfirmUpdateRoleModal({ + onConfirm, + onCancel, + memberEmail, + newRole, + isLoading = false, +}: ConfirmUpdateRoleModalProps) { + const { t } = useTranslation(); + + const confirmationMessage = ( + , + role: , + }} + /> + ); + + return ( + + ); +} diff --git a/frontend/src/components/features/org/delete-org-confirmation-modal.tsx b/frontend/src/components/features/org/delete-org-confirmation-modal.tsx new file mode 100644 index 0000000000..164355e6d2 --- /dev/null +++ b/frontend/src/components/features/org/delete-org-confirmation-modal.tsx @@ -0,0 +1,52 @@ +import { Trans, useTranslation } from "react-i18next"; +import { OrgModal } from "#/components/shared/modals/org-modal"; +import { I18nKey } from "#/i18n/declaration"; +import { useDeleteOrganization } from "#/hooks/mutation/use-delete-organization"; +import { useOrganization } from "#/hooks/query/use-organization"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; + +interface DeleteOrgConfirmationModalProps { + onClose: () => void; +} + +export function DeleteOrgConfirmationModal({ + onClose, +}: DeleteOrgConfirmationModalProps) { + const { t } = useTranslation(); + const { mutate: deleteOrganization, isPending } = useDeleteOrganization(); + const { data: organization } = useOrganization(); + + const handleConfirm = () => { + deleteOrganization(undefined, { + onSuccess: onClose, + onError: () => { + displayErrorToast(t(I18nKey.ORG$DELETE_ORGANIZATION_ERROR)); + }, + }); + }; + + const confirmationMessage = organization?.name ? ( + }} + /> + ) : ( + t(I18nKey.ORG$DELETE_ORGANIZATION_WARNING) + ); + + return ( + + ); +} diff --git a/frontend/src/components/features/org/invite-organization-member-modal.tsx b/frontend/src/components/features/org/invite-organization-member-modal.tsx new file mode 100644 index 0000000000..37ce1338cc --- /dev/null +++ b/frontend/src/components/features/org/invite-organization-member-modal.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { OrgModal } from "#/components/shared/modals/org-modal"; +import { useInviteMembersBatch } from "#/hooks/mutation/use-invite-members-batch"; +import { BadgeInput } from "#/components/shared/inputs/badge-input"; +import { I18nKey } from "#/i18n/declaration"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { areAllEmailsValid, hasDuplicates } from "#/utils/input-validation"; + +interface InviteOrganizationMemberModalProps { + onClose: (event?: React.MouseEvent) => void; +} + +export function InviteOrganizationMemberModal({ + onClose, +}: InviteOrganizationMemberModalProps) { + const { t } = useTranslation(); + const { mutate: inviteMembers, isPending } = useInviteMembersBatch(); + const [emails, setEmails] = React.useState([]); + + const handleEmailsChange = (newEmails: string[]) => { + const trimmedEmails = newEmails.map((email) => email.trim()); + setEmails(trimmedEmails); + }; + + const handleSubmit = () => { + if (emails.length === 0) { + displayErrorToast(t(I18nKey.ORG$NO_EMAILS_ADDED_HINT)); + return; + } + + if (!areAllEmailsValid(emails)) { + displayErrorToast(t(I18nKey.SETTINGS$INVALID_EMAIL_FORMAT)); + return; + } + + if (hasDuplicates(emails)) { + displayErrorToast(t(I18nKey.ORG$DUPLICATE_EMAILS_ERROR)); + return; + } + + inviteMembers( + { emails }, + { + onSuccess: () => onClose(), + }, + ); + }; + + return ( + + + + ); +} diff --git a/frontend/src/components/features/org/org-selector.tsx b/frontend/src/components/features/org/org-selector.tsx new file mode 100644 index 0000000000..d5b982a112 --- /dev/null +++ b/frontend/src/components/features/org/org-selector.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { useSwitchOrganization } from "#/hooks/mutation/use-switch-organization"; +import { useOrganizations } from "#/hooks/query/use-organizations"; +import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector"; +import { I18nKey } from "#/i18n/declaration"; +import { Organization } from "#/types/org"; +import { Dropdown } from "#/ui/dropdown/dropdown"; + +export function OrgSelector() { + const { t } = useTranslation(); + const { organizationId } = useSelectedOrganizationId(); + const { data, isLoading } = useOrganizations(); + const organizations = data?.organizations; + const { mutate: switchOrganization, isPending: isSwitching } = + useSwitchOrganization(); + const shouldHideSelector = useShouldHideOrgSelector(); + + const getOrgDisplayName = React.useCallback( + (org: Organization) => + org.is_personal ? t(I18nKey.ORG$PERSONAL_WORKSPACE) : org.name, + [t], + ); + + const selectedOrg = React.useMemo(() => { + if (organizationId) { + return organizations?.find((org) => org.id === organizationId); + } + + return organizations?.[0]; + }, [organizationId, organizations]); + + if (shouldHideSelector) { + return null; + } + + return ( + { + if (item && item.value !== organizationId) { + switchOrganization(item.value); + } + }} + placeholder={t(I18nKey.ORG$SELECT_ORGANIZATION_PLACEHOLDER)} + loading={isLoading || isSwitching} + options={ + organizations?.map((org) => ({ + value: org.id, + label: getOrgDisplayName(org), + })) || [] + } + /> + ); +} diff --git a/frontend/src/components/features/org/organization-member-list-item.tsx b/frontend/src/components/features/org/organization-member-list-item.tsx new file mode 100644 index 0000000000..fdff1f43bc --- /dev/null +++ b/frontend/src/components/features/org/organization-member-list-item.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown } from "lucide-react"; +import { OrganizationMember, OrganizationUserRole } from "#/types/org"; +import { cn } from "#/utils/utils"; +import { I18nKey } from "#/i18n/declaration"; +import { OrganizationMemberRoleContextMenu } from "./organization-member-role-context-menu"; + +interface OrganizationMemberListItemProps { + email: OrganizationMember["email"]; + role: OrganizationMember["role"]; + status: OrganizationMember["status"]; + hasPermissionToChangeRole: boolean; + availableRolesToChangeTo: OrganizationUserRole[]; + + onRoleChange: (role: OrganizationUserRole) => void; + onRemove?: () => void; +} + +export function OrganizationMemberListItem({ + email, + role, + status, + hasPermissionToChangeRole, + availableRolesToChangeTo, + onRoleChange, + onRemove, +}: OrganizationMemberListItemProps) { + const { t } = useTranslation(); + const [contextMenuOpen, setContextMenuOpen] = React.useState(false); + + const roleSelectionIsPermitted = + status !== "invited" && hasPermissionToChangeRole; + + const handleRoleClick = (event: React.MouseEvent) => { + if (roleSelectionIsPermitted) { + event.preventDefault(); + event.stopPropagation(); + setContextMenuOpen(true); + } + }; + + return ( +
+
+ + {email} + + + {status === "invited" && ( + + {t(I18nKey.ORG$STATUS_INVITED)} + + )} +
+ +
+ + {role} + {hasPermissionToChangeRole && } + + + {roleSelectionIsPermitted && contextMenuOpen && ( + setContextMenuOpen(false)} + onRoleChange={onRoleChange} + onRemove={onRemove} + availableRolesToChangeTo={availableRolesToChangeTo} + /> + )} +
+
+ ); +} diff --git a/frontend/src/components/features/org/organization-member-role-context-menu.tsx b/frontend/src/components/features/org/organization-member-role-context-menu.tsx new file mode 100644 index 0000000000..84278035e6 --- /dev/null +++ b/frontend/src/components/features/org/organization-member-role-context-menu.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { ContextMenu } from "#/ui/context-menu"; +import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; +import { ContextMenuIconText } from "#/ui/context-menu-icon-text"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; +import { OrganizationUserRole } from "#/types/org"; +import { cn } from "#/utils/utils"; +import UserIcon from "#/icons/user.svg?react"; +import DeleteIcon from "#/icons/u-delete.svg?react"; +import AdminIcon from "#/icons/admin.svg?react"; + +const contextMenuListItemClassName = cn( + "cursor-pointer p-0 h-auto hover:bg-transparent", +); + +interface OrganizationMemberRoleContextMenuProps { + onClose: () => void; + onRoleChange: (role: OrganizationUserRole) => void; + onRemove?: () => void; + availableRolesToChangeTo: OrganizationUserRole[]; +} + +export function OrganizationMemberRoleContextMenu({ + onClose, + onRoleChange, + onRemove, + availableRolesToChangeTo, +}: OrganizationMemberRoleContextMenuProps) { + const { t } = useTranslation(); + const menuRef = useClickOutsideElement(onClose); + + const handleRoleChangeClick = ( + event: React.MouseEvent, + role: OrganizationUserRole, + ) => { + event.preventDefault(); + event.stopPropagation(); + onRoleChange(role); + onClose(); + }; + + const handleRemoveClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onRemove?.(); + onClose(); + }; + + return ( + + {availableRolesToChangeTo.includes("owner") && ( + handleRoleChangeClick(event, "owner")} + className={contextMenuListItemClassName} + > + + } + text={t(I18nKey.ORG$ROLE_OWNER)} + className="capitalize" + /> + + )} + {availableRolesToChangeTo.includes("admin") && ( + handleRoleChangeClick(event, "admin")} + className={contextMenuListItemClassName} + > + + } + text={t(I18nKey.ORG$ROLE_ADMIN)} + className="capitalize" + /> + + )} + {availableRolesToChangeTo.includes("member") && ( + handleRoleChangeClick(event, "member")} + className={contextMenuListItemClassName} + > + } + text={t(I18nKey.ORG$ROLE_MEMBER)} + className="capitalize" + /> + + )} + + } + text={t(I18nKey.ORG$REMOVE)} + className="text-red-500 capitalize" + /> + + + ); +} diff --git a/frontend/src/components/features/payment/payment-form.tsx b/frontend/src/components/features/payment/payment-form.tsx index 926ae831e0..0ac9ede2d6 100644 --- a/frontend/src/components/features/payment/payment-form.tsx +++ b/frontend/src/components/features/payment/payment-form.tsx @@ -11,7 +11,7 @@ import { amountIsValid } from "#/utils/amount-is-valid"; import { I18nKey } from "#/i18n/declaration"; import { PoweredByStripeTag } from "./powered-by-stripe-tag"; -export function PaymentForm() { +export function PaymentForm({ isDisabled }: { isDisabled?: boolean }) { const { t } = useTranslation(); const { data: balance, isLoading } = useBalance(); const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession(); @@ -69,13 +69,14 @@ export function PaymentForm() { min={10} max={25000} step={1} + isDisabled={isDisabled} />
{t(I18nKey.PAYMENT$ADD_CREDIT)} diff --git a/frontend/src/components/features/payment/setup-payment-modal.tsx b/frontend/src/components/features/payment/setup-payment-modal.tsx index 7d8883a719..a6513840b3 100644 --- a/frontend/src/components/features/payment/setup-payment-modal.tsx +++ b/frontend/src/components/features/payment/setup-payment-modal.tsx @@ -31,7 +31,7 @@ export function SetupPaymentModal() { variant="primary" className="w-full" isDisabled={isPending} - onClick={mutate} + onClick={() => mutate()} > {t(I18nKey.BILLING$PROCEED_TO_STRIPE)} diff --git a/frontend/src/components/features/settings/brand-button.tsx b/frontend/src/components/features/settings/brand-button.tsx index 624a03e915..4dc98e3613 100644 --- a/frontend/src/components/features/settings/brand-button.tsx +++ b/frontend/src/components/features/settings/brand-button.tsx @@ -7,7 +7,7 @@ interface BrandButtonProps { type: React.ButtonHTMLAttributes["type"]; isDisabled?: boolean; className?: string; - onClick?: () => void; + onClick?: (event?: React.MouseEvent) => void; startContent?: React.ReactNode; } diff --git a/frontend/src/components/features/settings/settings-dropdown-input.tsx b/frontend/src/components/features/settings/settings-dropdown-input.tsx index da0595795d..01b3ef5325 100644 --- a/frontend/src/components/features/settings/settings-dropdown-input.tsx +++ b/frontend/src/components/features/settings/settings-dropdown-input.tsx @@ -63,7 +63,7 @@ export function SettingsDropdownInput({ aria-label={typeof label === "string" ? label : name} data-testid={testId} name={name} - defaultItems={items} + items={items} defaultSelectedKey={defaultSelectedKey} selectedKey={selectedKey} onSelectionChange={onSelectionChange} @@ -76,7 +76,7 @@ export function SettingsDropdownInput({ isRequired={required} className="w-full" classNames={{ - popoverContent: "bg-tertiary rounded-xl border border-[#717888]", + popoverContent: "bg-tertiary rounded-xl", }} inputProps={{ classNames: { diff --git a/frontend/src/components/features/settings/settings-navigation.tsx b/frontend/src/components/features/settings/settings-navigation.tsx index 5a35f01495..bbc4f57795 100644 --- a/frontend/src/components/features/settings/settings-navigation.tsx +++ b/frontend/src/components/features/settings/settings-navigation.tsx @@ -5,7 +5,9 @@ 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 { OrgSelector } from "../org/org-selector"; import { SettingsNavItem } from "#/constants/settings-nav"; +import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector"; interface SettingsNavigationProps { isMobileMenuOpen: boolean; @@ -19,6 +21,7 @@ export function SettingsNavigation({ navigationItems, }: SettingsNavigationProps) { const { t } = useTranslation(); + const shouldHideSelector = useShouldHideOrgSelector(); return ( <> @@ -50,13 +53,15 @@ export function SettingsNavigation({
+ {!shouldHideSelector && } +
{navigationItems.map(({ to, icon, text }) => ( cn( - "flex items-center gap-3 p-1 sm:px-[14px] sm:py-2 rounded-md transition-colors", - isActive ? "bg-[#454545]" : "hover:bg-[#454545]", + "group flex items-center gap-3 p-1 sm:px-3.5 sm:py-2 rounded-md transition-all duration-200", + isActive ? "bg-tertiary" : "hover:bg-tertiary", ) } > - {icon} -
- + + {icon} + +
+ {t(text as I18nKey)}
diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index f7b11cf2d7..258eda9e30 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -10,7 +10,6 @@ import { SettingsModal } from "#/components/shared/modals/settings/settings-moda import { useSettings } from "#/hooks/query/use-settings"; import { ConversationPanel } from "../conversation-panel/conversation-panel"; import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper"; -import { useLogout } from "#/hooks/mutation/use-logout"; import { useConfig } from "#/hooks/query/use-config"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; import { I18nKey } from "#/i18n/declaration"; @@ -27,7 +26,6 @@ export function Sidebar() { isError: settingsIsError, isFetching: isFetchingSettings, } = useSettings(); - const { mutate: logout } = useLogout(); const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false); @@ -96,7 +94,6 @@ export function Sidebar() { user={ user.data ? { avatar_url: user.data.avatar_url } : undefined } - onLogout={logout} isLoading={user.isFetching} />
diff --git a/frontend/src/components/features/sidebar/user-actions.tsx b/frontend/src/components/features/sidebar/user-actions.tsx index 56584f78f5..3620663789 100644 --- a/frontend/src/components/features/sidebar/user-actions.tsx +++ b/frontend/src/components/features/sidebar/user-actions.tsx @@ -1,74 +1,88 @@ import React from "react"; +import ReactDOM from "react-dom"; import { UserAvatar } from "./user-avatar"; -import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu"; +import { useMe } from "#/hooks/query/use-me"; import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features"; +import { UserContextMenu } from "../user/user-context-menu"; +import { InviteOrganizationMemberModal } from "../org/invite-organization-member-modal"; import { cn } from "#/utils/utils"; -import { useConfig } from "#/hooks/query/use-config"; interface UserActionsProps { - onLogout: () => void; user?: { avatar_url: string }; isLoading?: boolean; } -export function UserActions({ onLogout, user, isLoading }: UserActionsProps) { +export function UserActions({ user, isLoading }: UserActionsProps) { + const { data: me } = useMe(); const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] = React.useState(false); - - const { data: config } = useConfig(); + // Counter that increments each time the menu hides, used as a React key + // to force UserContextMenu to remount with fresh state (resets dropdown + // open/close, search text, and scroll position in the org selector). + const [menuResetCount, setMenuResetCount] = React.useState(0); + const [inviteMemberModalIsOpen, setInviteMemberModalIsOpen] = + React.useState(false); // Use the shared hook to determine if user actions should be shown const shouldShowUserActions = useShouldShowUserFeatures(); - const toggleAccountMenu = () => { - // Always toggle the menu, even if user is undefined - setAccountContextMenuIsVisible((prev) => !prev); + const showAccountMenu = () => { + setAccountContextMenuIsVisible(true); + }; + + const hideAccountMenu = () => { + setAccountContextMenuIsVisible(false); + setMenuResetCount((c) => c + 1); }; const closeAccountMenu = () => { if (accountContextMenuIsVisible) { setAccountContextMenuIsVisible(false); + setMenuResetCount((c) => c + 1); } }; - const handleLogout = () => { - onLogout(); - closeAccountMenu(); + const openInviteMemberModal = () => { + setInviteMemberModalIsOpen(true); }; - const isOSS = config?.app_mode === "oss"; - - // Show the menu based on the new logic - const showMenu = - accountContextMenuIsVisible && (shouldShowUserActions || isOSS); - return ( -
- + <> +
+ - {(shouldShowUserActions || isOSS) && ( -
- -
- )} -
+ {shouldShowUserActions && user && ( +
+ +
+ )} +
+ + {inviteMemberModalIsOpen && + ReactDOM.createPortal( + setInviteMemberModalIsOpen(false)} + />, + document.getElementById("portal-root") || document.body, + )} + ); } diff --git a/frontend/src/components/features/sidebar/user-avatar.tsx b/frontend/src/components/features/sidebar/user-avatar.tsx index 2f9b5e9d30..52e0ecc2e9 100644 --- a/frontend/src/components/features/sidebar/user-avatar.tsx +++ b/frontend/src/components/features/sidebar/user-avatar.tsx @@ -6,12 +6,11 @@ import { cn } from "#/utils/utils"; import { Avatar } from "./avatar"; interface UserAvatarProps { - onClick: () => void; avatarUrl?: string; isLoading?: boolean; } -export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) { +export function UserAvatar({ avatarUrl, isLoading }: UserAvatarProps) { const { t } = useTranslation(); return ( @@ -22,7 +21,6 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) { "w-8 h-8 rounded-full flex items-center justify-center cursor-pointer", isLoading && "bg-transparent", )} - onClick={onClick} > {!isLoading && avatarUrl && } {!isLoading && !avatarUrl && ( diff --git a/frontend/src/components/features/user/user-context-menu.tsx b/frontend/src/components/features/user/user-context-menu.tsx new file mode 100644 index 0000000000..c78d6c15f3 --- /dev/null +++ b/frontend/src/components/features/user/user-context-menu.tsx @@ -0,0 +1,168 @@ +import React from "react"; +import { Link, useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { + IoCardOutline, + IoLogOutOutline, + IoPersonAddOutline, +} from "react-icons/io5"; +import { FiUsers } from "react-icons/fi"; +import { useLogout } from "#/hooks/mutation/use-logout"; +import { OrganizationUserRole } from "#/types/org"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; +import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access"; +import { cn } from "#/utils/utils"; +import { OrgSelector } from "../org/org-selector"; +import { I18nKey } from "#/i18n/declaration"; +import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; +import DocumentIcon from "#/icons/document.svg?react"; +import { Divider } from "#/ui/divider"; +import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; +import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector"; + +// Shared className for context menu list items in the user context menu +const contextMenuListItemClassName = cn( + "flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded", +); + +interface UserContextMenuProps { + type: OrganizationUserRole; + onClose: () => void; + onOpenInviteModal: () => void; +} + +export function UserContextMenu({ + type, + onClose, + onOpenInviteModal, +}: UserContextMenuProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { mutate: logout } = useLogout(); + const { isPersonalOrg } = useOrgTypeAndAccess(); + const ref = useClickOutsideElement(onClose); + const settingsNavItems = useSettingsNavItems(); + const shouldHideSelector = useShouldHideOrgSelector(); + + // Filter out org routes since they're handled separately via buttons in this menu + const navItems = settingsNavItems.filter( + (item) => + item.to !== "/settings/org" && item.to !== "/settings/org-members", + ); + + const isMember = type === "member"; + + const handleLogout = () => { + logout(); + onClose(); + }; + + const handleInviteMemberClick = () => { + onOpenInviteModal(); + onClose(); + }; + + const handleManageOrganizationMembersClick = () => { + navigate("/settings/org-members"); + onClose(); + }; + + const handleManageAccountClick = () => { + navigate("/settings/org"); + onClose(); + }; + + return ( +
+

+ {t(I18nKey.ORG$ACCOUNT)} +

+ +
+ {!shouldHideSelector && ( +
+ +
+ )} + + {!isMember && !isPersonalOrg && ( +
+ + + {t(I18nKey.ORG$INVITE_ORG_MEMBERS)} + + + + + + + {t(I18nKey.COMMON$ORGANIZATION)} + + + + {t(I18nKey.ORG$ORGANIZATION_MEMBERS)} + + +
+ )} + +
+ {navItems.map((item) => ( + + {React.cloneElement(item.icon, { + className: "text-white", + width: 14, + height: 14, + } as React.SVGProps)} + {t(item.text)} + + ))} +
+ + + +
+ + + {t(I18nKey.SIDEBAR$DOCS)} + + + + + {t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)} + +
+
+
+ ); +} diff --git a/frontend/src/components/shared/inputs/badge-input.tsx b/frontend/src/components/shared/inputs/badge-input.tsx index 3d06bd4bf3..cc34db4293 100644 --- a/frontend/src/components/shared/inputs/badge-input.tsx +++ b/frontend/src/components/shared/inputs/badge-input.tsx @@ -8,6 +8,8 @@ interface BadgeInputProps { value: string[]; placeholder?: string; onChange: (value: string[]) => void; + className?: string; + inputClassName?: string; } export function BadgeInput({ @@ -15,6 +17,8 @@ export function BadgeInput({ value, placeholder, onChange, + className, + inputClassName, }: BadgeInputProps) { const [inputValue, setInputValue] = React.useState(""); @@ -45,6 +49,7 @@ export function BadgeInput({ className={cn( "bg-tertiary border border-[#717888] rounded w-full p-2 placeholder:italic placeholder:text-tertiary-alt", "flex flex-wrap items-center gap-2", + className, )} > {value.map((badge, index) => ( @@ -69,7 +74,7 @@ export function BadgeInput({ placeholder={value.length === 0 ? placeholder : ""} onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} - className="flex-grow outline-none bg-transparent" + className={cn("flex-grow outline-none bg-transparent", inputClassName)} />
); diff --git a/frontend/src/components/shared/loading-spinner.tsx b/frontend/src/components/shared/loading-spinner.tsx index 5f19d0fe4d..d304dde2a4 100644 --- a/frontend/src/components/shared/loading-spinner.tsx +++ b/frontend/src/components/shared/loading-spinner.tsx @@ -3,21 +3,35 @@ import { cn } from "#/utils/utils"; interface LoadingSpinnerProps { size: "small" | "large"; + className?: string; + innerClassName?: string; + outerClassName?: string; } -export function LoadingSpinner({ size }: LoadingSpinnerProps) { +export function LoadingSpinner({ + size, + className, + innerClassName, + outerClassName, +}: LoadingSpinnerProps) { const sizeStyle = size === "small" ? "w-[25px] h-[25px]" : "w-[50px] h-[50px]"; return ( -
+
- +
); } diff --git a/frontend/src/components/shared/modals/confirmation-modals/base-modal.tsx b/frontend/src/components/shared/modals/confirmation-modals/base-modal.tsx index c5b4e3f255..0cda57e832 100644 --- a/frontend/src/components/shared/modals/confirmation-modals/base-modal.tsx +++ b/frontend/src/components/shared/modals/confirmation-modals/base-modal.tsx @@ -30,7 +30,7 @@ export function BaseModalDescription({ children, }: BaseModalDescriptionProps) { return ( - {children || description} + {children || description} ); } diff --git a/frontend/src/components/shared/modals/modal-backdrop.tsx b/frontend/src/components/shared/modals/modal-backdrop.tsx index 6f8c16016f..301f13c328 100644 --- a/frontend/src/components/shared/modals/modal-backdrop.tsx +++ b/frontend/src/components/shared/modals/modal-backdrop.tsx @@ -3,9 +3,14 @@ import React from "react"; interface ModalBackdropProps { children: React.ReactNode; onClose?: () => void; + "aria-label"?: string; } -export function ModalBackdrop({ children, onClose }: ModalBackdropProps) { +export function ModalBackdrop({ + children, + onClose, + "aria-label": ariaLabel, +}: ModalBackdropProps) { React.useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") onClose?.(); @@ -20,7 +25,12 @@ export function ModalBackdrop({ children, onClose }: ModalBackdropProps) { }; return ( -
+
void; + onSecondaryClick: () => void; + isLoading?: boolean; + primaryType?: "button" | "submit"; + primaryTestId?: string; + secondaryTestId?: string; + fullWidth?: boolean; +} + +export function ModalButtonGroup({ + primaryText, + secondaryText, + onPrimaryClick, + onSecondaryClick, + isLoading = false, + primaryType = "button", + primaryTestId, + secondaryTestId, + fullWidth = false, +}: ModalButtonGroupProps) { + const { t } = useTranslation(); + const closeText = secondaryText ?? t(I18nKey.BUTTON$CLOSE); + + return ( +
+ + {isLoading ? ( + + ) : ( + primaryText + )} + + + {closeText} + +
+ ); +} diff --git a/frontend/src/components/shared/modals/org-modal.tsx b/frontend/src/components/shared/modals/org-modal.tsx new file mode 100644 index 0000000000..054fc1f673 --- /dev/null +++ b/frontend/src/components/shared/modals/org-modal.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { ModalBackdrop } from "./modal-backdrop"; +import { ModalBody } from "./modal-body"; +import { ModalButtonGroup } from "./modal-button-group"; + +interface OrgModalProps { + testId?: string; + title: string; + description?: React.ReactNode; + children?: React.ReactNode; + primaryButtonText: string; + secondaryButtonText?: string; + onPrimaryClick?: () => void; + onClose: () => void; + isLoading?: boolean; + primaryButtonType?: "button" | "submit"; + primaryButtonTestId?: string; + secondaryButtonTestId?: string; + ariaLabel?: string; + asForm?: boolean; + formAction?: (formData: FormData) => void; + fullWidthButtons?: boolean; +} + +export function OrgModal({ + testId, + title, + description, + children, + primaryButtonText, + secondaryButtonText, + onPrimaryClick, + onClose, + isLoading = false, + primaryButtonType = "button", + primaryButtonTestId, + secondaryButtonTestId, + ariaLabel, + asForm = false, + formAction, + fullWidthButtons = false, +}: OrgModalProps) { + const content = ( + <> +
+

{title}

+ {description && ( +

{description}

+ )} + {children} +
+ + + ); + + const modalBodyClassName = + "items-start rounded-xl p-6 w-sm flex flex-col gap-4 bg-base-secondary border border-tertiary"; + + return ( + + {asForm ? ( +
+ {content} +
+ ) : ( + + {content} + + )} +
+ ); +} diff --git a/frontend/src/components/v1/chat/event-message.tsx b/frontend/src/components/v1/chat/event-message.tsx index 0c55a9a0b4..fa6299e57a 100644 --- a/frontend/src/components/v1/chat/event-message.tsx +++ b/frontend/src/components/v1/chat/event-message.tsx @@ -126,7 +126,6 @@ const renderUserMessageWithSkillReady = ( ); } catch (error) { // If skill ready event creation fails, just render the user message - // Failed to create skill ready event, fallback to user message return ( , + }, + { + to: "/settings/org", + text: "Organization", + icon: , + }, ]; export const OSS_NAV_ITEMS: SettingsNavItem[] = [ diff --git a/frontend/src/context/use-selected-organization.ts b/frontend/src/context/use-selected-organization.ts new file mode 100644 index 0000000000..28c58ec4c4 --- /dev/null +++ b/frontend/src/context/use-selected-organization.ts @@ -0,0 +1,28 @@ +import { useRevalidator } from "react-router"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; + +interface SetOrganizationIdOptions { + /** Skip route revalidation. Useful for initial auto-selection to avoid duplicate API calls. */ + skipRevalidation?: boolean; +} + +export const useSelectedOrganizationId = () => { + const revalidator = useRevalidator(); + const { organizationId, setOrganizationId: setOrganizationIdStore } = + useSelectedOrganizationStore(); + + const setOrganizationId = ( + newOrganizationId: string | null, + options?: SetOrganizationIdOptions, + ) => { + setOrganizationIdStore(newOrganizationId); + // Revalidate route to ensure the latest orgId is used. + // This is useful for redirecting the user away from admin-only org pages. + // Skip revalidation for initial auto-selection to avoid duplicate API calls. + if (!options?.skipRevalidation) { + revalidator.revalidate(); + } + }; + + return { organizationId, setOrganizationId }; +}; diff --git a/frontend/src/hooks/mutation/use-delete-organization.ts b/frontend/src/hooks/mutation/use-delete-organization.ts new file mode 100644 index 0000000000..e8d41da277 --- /dev/null +++ b/frontend/src/hooks/mutation/use-delete-organization.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useDeleteOrganization = () => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { organizationId, setOrganizationId } = useSelectedOrganizationId(); + + return useMutation({ + mutationFn: () => { + if (!organizationId) throw new Error("Organization ID is required"); + return organizationService.deleteOrganization({ orgId: organizationId }); + }, + onSuccess: () => { + // Remove stale cache BEFORE clearing the selected organization. + // This prevents useAutoSelectOrganization from using the old currentOrgId + // when it runs during the re-render triggered by setOrganizationId(null). + // Using removeQueries (not invalidateQueries) ensures stale data is gone immediately. + queryClient.removeQueries({ + queryKey: ["organizations"], + exact: true, + }); + queryClient.removeQueries({ + queryKey: ["organizations", organizationId], + }); + + // Now clear the selected organization - useAutoSelectOrganization will + // wait for fresh data since the cache is empty + setOrganizationId(null); + + navigate("/"); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-invite-members-batch.ts b/frontend/src/hooks/mutation/use-invite-members-batch.ts new file mode 100644 index 0000000000..d82287da2c --- /dev/null +++ b/frontend/src/hooks/mutation/use-invite-members-batch.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { I18nKey } from "#/i18n/declaration"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; + +export const useInviteMembersBatch = () => { + const queryClient = useQueryClient(); + const { organizationId } = useSelectedOrganizationId(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: ({ emails }: { emails: string[] }) => { + if (!organizationId) { + throw new Error("Organization ID is required"); + } + return organizationService.inviteMembers({ + orgId: organizationId, + emails, + }); + }, + onSuccess: () => { + displaySuccessToast(t(I18nKey.ORG$INVITE_MEMBERS_SUCCESS)); + queryClient.invalidateQueries({ + queryKey: ["organizations", "members", organizationId], + }); + }, + onError: (error) => { + const errorMessage = retrieveAxiosErrorMessage(error); + displayErrorToast(errorMessage || t(I18nKey.ORG$INVITE_MEMBERS_ERROR)); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-remove-member.ts b/frontend/src/hooks/mutation/use-remove-member.ts new file mode 100644 index 0000000000..583fe5a415 --- /dev/null +++ b/frontend/src/hooks/mutation/use-remove-member.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { I18nKey } from "#/i18n/declaration"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; + +export const useRemoveMember = () => { + const queryClient = useQueryClient(); + const { organizationId } = useSelectedOrganizationId(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: ({ userId }: { userId: string }) => { + if (!organizationId) { + throw new Error("Organization ID is required"); + } + return organizationService.removeMember({ + orgId: organizationId, + userId, + }); + }, + onSuccess: () => { + displaySuccessToast(t(I18nKey.ORG$REMOVE_MEMBER_SUCCESS)); + queryClient.invalidateQueries({ + queryKey: ["organizations", "members", organizationId], + }); + }, + onError: (error) => { + const errorMessage = retrieveAxiosErrorMessage(error); + displayErrorToast(errorMessage || t(I18nKey.ORG$REMOVE_MEMBER_ERROR)); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-switch-organization.ts b/frontend/src/hooks/mutation/use-switch-organization.ts new file mode 100644 index 0000000000..45fadedaf4 --- /dev/null +++ b/frontend/src/hooks/mutation/use-switch-organization.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMatch, useNavigate } from "react-router"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useSwitchOrganization = () => { + const queryClient = useQueryClient(); + const { setOrganizationId } = useSelectedOrganizationId(); + const navigate = useNavigate(); + const conversationMatch = useMatch("/conversations/:conversationId"); + + return useMutation({ + mutationFn: (orgId: string) => + organizationService.switchOrganization({ orgId }), + onSuccess: (_, orgId) => { + // Invalidate the target org's /me query to ensure fresh data on every switch + queryClient.invalidateQueries({ + queryKey: ["organizations", orgId, "me"], + }); + // Update local state + setOrganizationId(orgId); + // Invalidate settings for the new org context + queryClient.invalidateQueries({ queryKey: ["settings"] }); + // Invalidate conversations to fetch data for the new org context + queryClient.invalidateQueries({ queryKey: ["user", "conversations"] }); + // Remove all individual conversation queries to clear any stale/null data + // from the previous org context + queryClient.removeQueries({ queryKey: ["user", "conversation"] }); + + // Redirect to home if on a conversation page since org context has changed + if (conversationMatch) { + navigate("/"); + } + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-unified-start-conversation.ts b/frontend/src/hooks/mutation/use-unified-start-conversation.ts index 778ba25359..fafa0f98dd 100644 --- a/frontend/src/hooks/mutation/use-unified-start-conversation.ts +++ b/frontend/src/hooks/mutation/use-unified-start-conversation.ts @@ -33,6 +33,20 @@ export const useUnifiedResumeConversationSandbox = () => { providers?: Provider[]; version?: "V0" | "V1"; }) => { + // Guard: If conversation is no longer in cache and no explicit version provided, + // skip the mutation. This handles race conditions like org switching where cache + // is cleared before the mutation executes. + // We return undefined (not throw) to avoid triggering the global MutationCache.onError + // handler which would display an error toast to the user. + const cachedConversation = queryClient.getQueryData([ + "user", + "conversation", + variables.conversationId, + ]); + if (!cachedConversation && !variables.version) { + return undefined; + } + // Use provided version or fallback to cache lookup const version = variables.version || diff --git a/frontend/src/hooks/mutation/use-update-member-role.ts b/frontend/src/hooks/mutation/use-update-member-role.ts new file mode 100644 index 0000000000..cb398c6246 --- /dev/null +++ b/frontend/src/hooks/mutation/use-update-member-role.ts @@ -0,0 +1,46 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { OrganizationUserRole } from "#/types/org"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { I18nKey } from "#/i18n/declaration"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; + +export const useUpdateMemberRole = () => { + const queryClient = useQueryClient(); + const { organizationId } = useSelectedOrganizationId(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async ({ + userId, + role, + }: { + userId: string; + role: OrganizationUserRole; + }) => { + if (!organizationId) { + throw new Error("Organization ID is required to update member role"); + } + return organizationService.updateMember({ + orgId: organizationId, + userId, + role, + }); + }, + onSuccess: () => { + displaySuccessToast(t(I18nKey.ORG$UPDATE_ROLE_SUCCESS)); + queryClient.invalidateQueries({ + queryKey: ["organizations", "members", organizationId], + }); + }, + onError: (error) => { + const errorMessage = retrieveAxiosErrorMessage(error); + displayErrorToast(errorMessage || t(I18nKey.ORG$UPDATE_ROLE_ERROR)); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-update-organization.ts b/frontend/src/hooks/mutation/use-update-organization.ts new file mode 100644 index 0000000000..a7ba9f1dfc --- /dev/null +++ b/frontend/src/hooks/mutation/use-update-organization.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useUpdateOrganization = () => { + const queryClient = useQueryClient(); + const { organizationId } = useSelectedOrganizationId(); + + return useMutation({ + mutationFn: (name: string) => { + if (!organizationId) throw new Error("Organization ID is required"); + return organizationService.updateOrganization({ + orgId: organizationId, + name, + }); + }, + onSuccess: () => { + // Invalidate the specific organization query + queryClient.invalidateQueries({ + queryKey: ["organizations", organizationId], + }); + // Invalidate the organizations list to refresh org-selector + queryClient.invalidateQueries({ + queryKey: ["organizations"], + }); + }, + }); +}; diff --git a/frontend/src/hooks/organizations/use-permissions.ts b/frontend/src/hooks/organizations/use-permissions.ts new file mode 100644 index 0000000000..f6f1e4f0c9 --- /dev/null +++ b/frontend/src/hooks/organizations/use-permissions.ts @@ -0,0 +1,17 @@ +import { useMemo } from "react"; +import { OrganizationUserRole } from "#/types/org"; +import { rolePermissions, PermissionKey } from "#/utils/org/permissions"; + +export const usePermission = (role: OrganizationUserRole) => { + /* Memoize permissions for the role */ + const currentPermissions = useMemo( + () => rolePermissions[role], + [role], + ); + + /* Check if the user has a specific permission */ + const hasPermission = (permission: PermissionKey): boolean => + currentPermissions.includes(permission); + + return { hasPermission }; +}; diff --git a/frontend/src/hooks/query/use-me.ts b/frontend/src/hooks/query/use-me.ts new file mode 100644 index 0000000000..137bb151ec --- /dev/null +++ b/frontend/src/hooks/query/use-me.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { useConfig } from "./use-config"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useMe = () => { + const { data: config } = useConfig(); + const { organizationId } = useSelectedOrganizationId(); + + const isSaas = config?.app_mode === "saas"; + + return useQuery({ + queryKey: ["organizations", organizationId, "me"], + queryFn: () => organizationService.getMe({ orgId: organizationId! }), + staleTime: 1000 * 60 * 5, // 5 minutes + enabled: isSaas && !!organizationId, + }); +}; diff --git a/frontend/src/hooks/query/use-organization-members-count.ts b/frontend/src/hooks/query/use-organization-members-count.ts new file mode 100644 index 0000000000..9917f390fe --- /dev/null +++ b/frontend/src/hooks/query/use-organization-members-count.ts @@ -0,0 +1,24 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +interface UseOrganizationMembersCountParams { + email?: string; +} + +export const useOrganizationMembersCount = ({ + email, +}: UseOrganizationMembersCountParams = {}) => { + const { organizationId } = useSelectedOrganizationId(); + + return useQuery({ + queryKey: ["organizations", "members", "count", organizationId, email], + queryFn: () => + organizationService.getOrganizationMembersCount({ + orgId: organizationId!, + email: email || undefined, + }), + enabled: !!organizationId, + placeholderData: keepPreviousData, + }); +}; diff --git a/frontend/src/hooks/query/use-organization-members.ts b/frontend/src/hooks/query/use-organization-members.ts new file mode 100644 index 0000000000..559f8598e2 --- /dev/null +++ b/frontend/src/hooks/query/use-organization-members.ts @@ -0,0 +1,30 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +interface UseOrganizationMembersParams { + page?: number; + limit?: number; + email?: string; +} + +export const useOrganizationMembers = ({ + page = 1, + limit = 10, + email, +}: UseOrganizationMembersParams = {}) => { + const { organizationId } = useSelectedOrganizationId(); + + return useQuery({ + queryKey: ["organizations", "members", organizationId, page, limit, email], + queryFn: () => + organizationService.getOrganizationMembers({ + orgId: organizationId!, + page, + limit, + email: email || undefined, + }), + enabled: !!organizationId, + placeholderData: keepPreviousData, + }); +}; diff --git a/frontend/src/hooks/query/use-organization-payment-info.tsx b/frontend/src/hooks/query/use-organization-payment-info.tsx new file mode 100644 index 0000000000..736673704d --- /dev/null +++ b/frontend/src/hooks/query/use-organization-payment-info.tsx @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useOrganizationPaymentInfo = () => { + const { organizationId } = useSelectedOrganizationId(); + + return useQuery({ + queryKey: ["organizations", organizationId, "payment"], + queryFn: () => + organizationService.getOrganizationPaymentInfo({ + orgId: organizationId!, + }), + enabled: !!organizationId, + }); +}; diff --git a/frontend/src/hooks/query/use-organization.ts b/frontend/src/hooks/query/use-organization.ts new file mode 100644 index 0000000000..337eb8601a --- /dev/null +++ b/frontend/src/hooks/query/use-organization.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; + +export const useOrganization = () => { + const { organizationId } = useSelectedOrganizationId(); + + return useQuery({ + queryKey: ["organizations", organizationId], + queryFn: () => + organizationService.getOrganization({ orgId: organizationId! }), + enabled: !!organizationId, + }); +}; diff --git a/frontend/src/hooks/query/use-organizations.ts b/frontend/src/hooks/query/use-organizations.ts new file mode 100644 index 0000000000..33d2b82e1f --- /dev/null +++ b/frontend/src/hooks/query/use-organizations.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { useIsAuthed } from "./use-is-authed"; +import { useConfig } from "./use-config"; + +export const useOrganizations = () => { + const { data: userIsAuthenticated } = useIsAuthed(); + const { data: config } = useConfig(); + + // Organizations are a SaaS-only feature - disable in OSS mode + const isOssMode = config?.app_mode === "oss"; + + return useQuery({ + queryKey: ["organizations"], + queryFn: organizationService.getOrganizations, + staleTime: 1000 * 60 * 5, // 5 minutes + enabled: !!userIsAuthenticated && !isOssMode, + select: (data) => ({ + // Sort organizations with personal workspace first, then alphabetically by name + organizations: [...data.items].sort((a, b) => { + const aIsPersonal = a.is_personal ?? false; + const bIsPersonal = b.is_personal ?? false; + if (aIsPersonal && !bIsPersonal) return -1; + if (!aIsPersonal && bIsPersonal) return 1; + return (a.name ?? "").localeCompare(b.name ?? "", undefined, { + sensitivity: "base", + }); + }), + currentOrgId: data.currentOrgId, + }), + }); +}; diff --git a/frontend/src/hooks/use-auto-select-organization.ts b/frontend/src/hooks/use-auto-select-organization.ts new file mode 100644 index 0000000000..fd226a8ba2 --- /dev/null +++ b/frontend/src/hooks/use-auto-select-organization.ts @@ -0,0 +1,33 @@ +import React from "react"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { useOrganizations } from "#/hooks/query/use-organizations"; + +/** + * Hook that automatically selects an organization when: + * - No organization is currently selected in the frontend store + * - Organizations data is available + * + * Selection priority: + * 1. Backend's current_org_id (user's last selected organization, persisted server-side) + * 2. First organization in the list (fallback for new users) + * + * This hook should be called from a component that always renders (e.g., root layout) + * to ensure organization selection happens even when the OrgSelector component is hidden. + */ +export function useAutoSelectOrganization() { + const { organizationId, setOrganizationId } = useSelectedOrganizationId(); + const { data } = useOrganizations(); + const organizations = data?.organizations; + const currentOrgId = data?.currentOrgId; + + React.useEffect(() => { + if (!organizationId && organizations && organizations.length > 0) { + // Prefer backend's current_org_id (last selected org), fall back to first org + const initialOrgId = currentOrgId ?? organizations[0].id; + // Skip revalidation for initial auto-selection to avoid duplicate API calls. + // Revalidation is only needed when user explicitly switches organizations + // to redirect away from admin-only pages they may no longer have access to. + setOrganizationId(initialOrgId, { skipRevalidation: true }); + } + }, [organizationId, organizations, currentOrgId, setOrganizationId]); +} diff --git a/frontend/src/hooks/use-org-type-and-access.ts b/frontend/src/hooks/use-org-type-and-access.ts new file mode 100644 index 0000000000..5f49b9220c --- /dev/null +++ b/frontend/src/hooks/use-org-type-and-access.ts @@ -0,0 +1,22 @@ +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { useOrganizations } from "#/hooks/query/use-organizations"; + +export const useOrgTypeAndAccess = () => { + const { organizationId } = useSelectedOrganizationId(); + const { data } = useOrganizations(); + const organizations = data?.organizations; + + const selectedOrg = organizations?.find((org) => org.id === organizationId); + const isPersonalOrg = selectedOrg?.is_personal === true; + // Team org = any org that is not explicitly marked as personal (includes undefined) + const isTeamOrg = !!selectedOrg && !selectedOrg.is_personal; + const canViewOrgRoutes = isTeamOrg && !!organizationId; + + return { + selectedOrg, + isPersonalOrg, + isTeamOrg, + canViewOrgRoutes, + organizationId, + }; +}; diff --git a/frontend/src/hooks/use-settings-nav-items.ts b/frontend/src/hooks/use-settings-nav-items.ts index fa0187251d..236d086ff6 100644 --- a/frontend/src/hooks/use-settings-nav-items.ts +++ b/frontend/src/hooks/use-settings-nav-items.ts @@ -1,14 +1,60 @@ import { useConfig } from "#/hooks/query/use-config"; -import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav"; -import { isSettingsPageHidden } from "#/routes/settings"; +import { + SAAS_NAV_ITEMS, + OSS_NAV_ITEMS, + SettingsNavItem, +} from "#/constants/settings-nav"; +import { OrganizationUserRole } from "#/types/org"; +import { isBillingHidden } from "#/utils/org/billing-visibility"; +import { isSettingsPageHidden } from "#/utils/settings-utils"; +import { useMe } from "./query/use-me"; +import { usePermission } from "./organizations/use-permissions"; +import { useOrgTypeAndAccess } from "./use-org-type-and-access"; -export function useSettingsNavItems() { +/** + * Build Settings navigation items based on: + * - app mode (saas / oss) + * - feature flags + * - active user's role + * - org type (personal vs team) + * @returns Settings Nav Items [] + */ +export function useSettingsNavItems(): SettingsNavItem[] { const { data: config } = useConfig(); + const { data: user } = useMe(); + const userRole: OrganizationUserRole = user?.role ?? "member"; + const { hasPermission } = usePermission(userRole); + const { isPersonalOrg, isTeamOrg, organizationId } = useOrgTypeAndAccess(); + const shouldHideBilling = isBillingHidden( + config, + hasPermission("view_billing"), + ); const isSaasMode = config?.app_mode === "saas"; const featureFlags = config?.feature_flags; - const items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; + let items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS; - return items.filter((item) => !isSettingsPageHidden(item.to, featureFlags)); + // First apply feature flag-based hiding + items = items.filter((item) => !isSettingsPageHidden(item.to, featureFlags)); + + // Hide billing when billing is not accessible OR when in team org + if (shouldHideBilling || isTeamOrg) { + items = items.filter((item) => item.to !== "/settings/billing"); + } + + // Hide org routes for personal orgs, missing permissions, or no org selected + if (!hasPermission("view_billing") || !organizationId || isPersonalOrg) { + items = items.filter((item) => item.to !== "/settings/org"); + } + + if ( + !hasPermission("invite_user_to_organization") || + !organizationId || + isPersonalOrg + ) { + items = items.filter((item) => item.to !== "/settings/org-members"); + } + + return items; } diff --git a/frontend/src/hooks/use-should-hide-org-selector.ts b/frontend/src/hooks/use-should-hide-org-selector.ts new file mode 100644 index 0000000000..27a5c6009c --- /dev/null +++ b/frontend/src/hooks/use-should-hide-org-selector.ts @@ -0,0 +1,16 @@ +import { useOrganizations } from "#/hooks/query/use-organizations"; +import { useConfig } from "#/hooks/query/use-config"; + +export function useShouldHideOrgSelector() { + const { data: config } = useConfig(); + const { data } = useOrganizations(); + const organizations = data?.organizations; + + // Always hide in OSS mode - organizations are a SaaS feature + if (config?.app_mode === "oss") { + return true; + } + + // In SaaS mode, hide if user only has one personal org + return organizations?.length === 1 && organizations[0]?.is_personal === true; +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index b968be1ec9..aac6e1b0f6 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -213,6 +213,7 @@ export enum I18nKey { BUTTON$END_SESSION = "BUTTON$END_SESSION", BUTTON$LAUNCH = "BUTTON$LAUNCH", BUTTON$CANCEL = "BUTTON$CANCEL", + BUTTON$ADD = "BUTTON$ADD", EXIT_PROJECT$CONFIRM = "EXIT_PROJECT$CONFIRM", EXIT_PROJECT$TITLE = "EXIT_PROJECT$TITLE", LANGUAGE$LABEL = "LANGUAGE$LABEL", @@ -734,6 +735,11 @@ export enum I18nKey { TASKS$TASK_SUGGESTIONS_INFO = "TASKS$TASK_SUGGESTIONS_INFO", TASKS$TASK_SUGGESTIONS_TOOLTIP = "TASKS$TASK_SUGGESTIONS_TOOLTIP", PAYMENT$SPECIFY_AMOUNT_USD = "PAYMENT$SPECIFY_AMOUNT_USD", + PAYMENT$ERROR_INVALID_NUMBER = "PAYMENT$ERROR_INVALID_NUMBER", + PAYMENT$ERROR_NEGATIVE_AMOUNT = "PAYMENT$ERROR_NEGATIVE_AMOUNT", + PAYMENT$ERROR_MINIMUM_AMOUNT = "PAYMENT$ERROR_MINIMUM_AMOUNT", + PAYMENT$ERROR_MAXIMUM_AMOUNT = "PAYMENT$ERROR_MAXIMUM_AMOUNT", + PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER = "PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER", GIT$BITBUCKET_TOKEN_HELP_LINK = "GIT$BITBUCKET_TOKEN_HELP_LINK", GIT$BITBUCKET_TOKEN_SEE_MORE_LINK = "GIT$BITBUCKET_TOKEN_SEE_MORE_LINK", GIT$BITBUCKET_DC_TOKEN_HELP_LINK = "GIT$BITBUCKET_DC_TOKEN_HELP_LINK", @@ -780,6 +786,7 @@ export enum I18nKey { COMMON$PERSONAL = "COMMON$PERSONAL", COMMON$REPOSITORIES = "COMMON$REPOSITORIES", COMMON$ORGANIZATIONS = "COMMON$ORGANIZATIONS", + COMMON$ORGANIZATION = "COMMON$ORGANIZATION", COMMON$ADD_MICROAGENT = "COMMON$ADD_MICROAGENT", COMMON$CREATED_ON = "COMMON$CREATED_ON", MICROAGENT_MANAGEMENT$LEARN_THIS_REPO = "MICROAGENT_MANAGEMENT$LEARN_THIS_REPO", @@ -906,6 +913,7 @@ export enum I18nKey { ACTION$CONFIRM_DELETE = "ACTION$CONFIRM_DELETE", ACTION$CONFIRM_STOP = "ACTION$CONFIRM_STOP", ACTION$CONFIRM_CLOSE = "ACTION$CONFIRM_CLOSE", + ACTION$CONFIRM_UPDATE = "ACTION$CONFIRM_UPDATE", AGENT_STATUS$AGENT_STOPPED = "AGENT_STATUS$AGENT_STOPPED", AGENT_STATUS$ERROR_OCCURRED = "AGENT_STATUS$ERROR_OCCURRED", AGENT_STATUS$INITIALIZING = "AGENT_STATUS$INITIALIZING", @@ -1005,6 +1013,46 @@ export enum I18nKey { COMMON$PLAN_AGENT_DESCRIPTION = "COMMON$PLAN_AGENT_DESCRIPTION", PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED = "PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED", OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY", + ORG$ORGANIZATION_NAME = "ORG$ORGANIZATION_NAME", + ORG$NEXT = "ORG$NEXT", + ORG$INVITE_USERS_DESCRIPTION = "ORG$INVITE_USERS_DESCRIPTION", + ORG$EMAILS = "ORG$EMAILS", + ORG$STATUS_INVITED = "ORG$STATUS_INVITED", + ORG$ROLE_ADMIN = "ORG$ROLE_ADMIN", + ORG$ROLE_MEMBER = "ORG$ROLE_MEMBER", + ORG$ROLE_OWNER = "ORG$ROLE_OWNER", + ORG$REMOVE = "ORG$REMOVE", + ORG$CONFIRM_REMOVE_MEMBER = "ORG$CONFIRM_REMOVE_MEMBER", + ORG$REMOVE_MEMBER_WARNING = "ORG$REMOVE_MEMBER_WARNING", + ORG$REMOVE_MEMBER_ERROR = "ORG$REMOVE_MEMBER_ERROR", + ORG$REMOVE_MEMBER_SUCCESS = "ORG$REMOVE_MEMBER_SUCCESS", + ORG$CONFIRM_UPDATE_ROLE = "ORG$CONFIRM_UPDATE_ROLE", + ORG$UPDATE_ROLE_WARNING = "ORG$UPDATE_ROLE_WARNING", + ORG$UPDATE_ROLE_SUCCESS = "ORG$UPDATE_ROLE_SUCCESS", + ORG$UPDATE_ROLE_ERROR = "ORG$UPDATE_ROLE_ERROR", + ORG$INVITE_MEMBERS_SUCCESS = "ORG$INVITE_MEMBERS_SUCCESS", + ORG$INVITE_MEMBERS_ERROR = "ORG$INVITE_MEMBERS_ERROR", + ORG$DUPLICATE_EMAILS_ERROR = "ORG$DUPLICATE_EMAILS_ERROR", + ORG$NO_EMAILS_ADDED_HINT = "ORG$NO_EMAILS_ADDED_HINT", + ORG$ACCOUNT = "ORG$ACCOUNT", + ORG$INVITE_TEAM = "ORG$INVITE_TEAM", + ORG$MANAGE_TEAM = "ORG$MANAGE_TEAM", + ORG$CHANGE_ORG_NAME = "ORG$CHANGE_ORG_NAME", + ORG$MODIFY_ORG_NAME_DESCRIPTION = "ORG$MODIFY_ORG_NAME_DESCRIPTION", + ORG$ADD_CREDITS = "ORG$ADD_CREDITS", + ORG$CREDITS = "ORG$CREDITS", + ORG$ADD = "ORG$ADD", + ORG$BILLING_INFORMATION = "ORG$BILLING_INFORMATION", + ORG$CHANGE = "ORG$CHANGE", + ORG$DELETE_ORGANIZATION = "ORG$DELETE_ORGANIZATION", + ORG$DELETE_ORGANIZATION_WARNING = "ORG$DELETE_ORGANIZATION_WARNING", + ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME = "ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME", + ORG$DELETE_ORGANIZATION_ERROR = "ORG$DELETE_ORGANIZATION_ERROR", + ACCOUNT_SETTINGS$SETTINGS = "ACCOUNT_SETTINGS$SETTINGS", + ORG$MANAGE_ORGANIZATION_MEMBERS = "ORG$MANAGE_ORGANIZATION_MEMBERS", + ORG$SELECT_ORGANIZATION_PLACEHOLDER = "ORG$SELECT_ORGANIZATION_PLACEHOLDER", + ORG$PERSONAL_WORKSPACE = "ORG$PERSONAL_WORKSPACE", + ORG$ENTER_NEW_ORGANIZATION_NAME = "ORG$ENTER_NEW_ORGANIZATION_NAME", CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS", SKILLS_MODAL$TITLE = "SKILLS_MODAL$TITLE", CONVERSATION$SHARE_PUBLICLY = "CONVERSATION$SHARE_PUBLICLY", @@ -1022,6 +1070,15 @@ export enum I18nKey { CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE", CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION", CONVERSATION$LINK_COPIED = "CONVERSATION$LINK_COPIED", + COMMON$TYPE_EMAIL_AND_PRESS_SPACE = "COMMON$TYPE_EMAIL_AND_PRESS_SPACE", + ORG$INVITE_ORG_MEMBERS = "ORG$INVITE_ORG_MEMBERS", + ORG$MANAGE_ORGANIZATION = "ORG$MANAGE_ORGANIZATION", + ORG$ORGANIZATION_MEMBERS = "ORG$ORGANIZATION_MEMBERS", + ORG$ALL_ORGANIZATION_MEMBERS = "ORG$ALL_ORGANIZATION_MEMBERS", + ORG$SEARCH_BY_EMAIL = "ORG$SEARCH_BY_EMAIL", + ORG$NO_MEMBERS_FOUND = "ORG$NO_MEMBERS_FOUND", + ORG$NO_MEMBERS_MATCHING_FILTER = "ORG$NO_MEMBERS_MATCHING_FILTER", + ORG$FAILED_TO_LOAD_MEMBERS = "ORG$FAILED_TO_LOAD_MEMBERS", ONBOARDING$STEP1_TITLE = "ONBOARDING$STEP1_TITLE", ONBOARDING$STEP1_SUBTITLE = "ONBOARDING$STEP1_SUBTITLE", ONBOARDING$SOFTWARE_ENGINEER = "ONBOARDING$SOFTWARE_ENGINEER", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 1817da31f7..129784a357 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -3407,6 +3407,22 @@ "de": "Abbrechen", "uk": "Скасувати" }, + "BUTTON$ADD": { + "en": "Add", + "ja": "追加", + "zh-CN": "添加", + "zh-TW": "新增", + "ko-KR": "추가", + "no": "Legg til", + "it": "Aggiungi", + "pt": "Adicionar", + "es": "Añadir", + "ar": "إضافة", + "fr": "Ajouter", + "tr": "Ekle", + "de": "Hinzufügen", + "uk": "Додати" + }, "EXIT_PROJECT$CONFIRM": { "en": "Exit Project", "ja": "プロジェクトを終了", @@ -11747,6 +11763,86 @@ "de": "Geben Sie einen USD-Betrag zum Hinzufügen an - min $10", "uk": "Вкажіть суму в доларах США для додавання - мін $10" }, + "PAYMENT$ERROR_INVALID_NUMBER": { + "en": "Please enter a valid number", + "ja": "有効な数値を入力してください", + "zh-CN": "请输入有效数字", + "zh-TW": "請輸入有效數字", + "ko-KR": "유효한 숫자를 입력하세요", + "no": "Vennligst skriv inn et gyldig tall", + "it": "Inserisci un numero valido", + "pt": "Por favor, insira um número válido", + "es": "Por favor, ingrese un número válido", + "ar": "يرجى إدخال رقم صحيح", + "fr": "Veuillez entrer un nombre valide", + "tr": "Lütfen geçerli bir sayı girin", + "de": "Bitte geben Sie eine gültige Zahl ein", + "uk": "Будь ласка, введіть дійсне число" + }, + "PAYMENT$ERROR_NEGATIVE_AMOUNT": { + "en": "Amount cannot be negative", + "ja": "金額は負の値にできません", + "zh-CN": "金额不能为负数", + "zh-TW": "金額不能為負數", + "ko-KR": "금액은 음수일 수 없습니다", + "no": "Beløpet kan ikke være negativt", + "it": "L'importo non può essere negativo", + "pt": "O valor não pode ser negativo", + "es": "El monto no puede ser negativo", + "ar": "لا يمكن أن يكون المبلغ سالبًا", + "fr": "Le montant ne peut pas être négatif", + "tr": "Tutar negatif olamaz", + "de": "Der Betrag darf nicht negativ sein", + "uk": "Сума не може бути від'ємною" + }, + "PAYMENT$ERROR_MINIMUM_AMOUNT": { + "en": "Minimum amount is $10", + "ja": "最小金額は$10です", + "zh-CN": "最低金额为$10", + "zh-TW": "最低金額為$10", + "ko-KR": "최소 금액은 $10입니다", + "no": "Minimumsbeløpet er $10", + "it": "L'importo minimo è $10", + "pt": "O valor mínimo é $10", + "es": "El monto mínimo es $10", + "ar": "الحد الأدنى للمبلغ هو 10 دولارات", + "fr": "Le montant minimum est de 10 $", + "tr": "Minimum tutar $10'dur", + "de": "Der Mindestbetrag beträgt 10 $", + "uk": "Мінімальна сума становить $10" + }, + "PAYMENT$ERROR_MAXIMUM_AMOUNT": { + "en": "Maximum amount is $25,000", + "ja": "最大金額は$25,000です", + "zh-CN": "最高金额为$25,000", + "zh-TW": "最高金額為$25,000", + "ko-KR": "최대 금액은 $25,000입니다", + "no": "Maksimalbeløpet er $25,000", + "it": "L'importo massimo è $25,000", + "pt": "O valor máximo é $25,000", + "es": "El monto máximo es $25,000", + "ar": "الحد الأقصى للمبلغ هو 25,000 دولار", + "fr": "Le montant maximum est de 25 000 $", + "tr": "Maksimum tutar $25,000'dur", + "de": "Der Höchstbetrag beträgt 25.000 $", + "uk": "Максимальна сума становить $25,000" + }, + "PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER": { + "en": "Amount must be a whole number", + "ja": "金額は整数である必要があります", + "zh-CN": "金额必须是整数", + "zh-TW": "金額必須是整數", + "ko-KR": "금액은 정수여야 합니다", + "no": "Beløpet må være et heltall", + "it": "L'importo deve essere un numero intero", + "pt": "O valor deve ser um número inteiro", + "es": "El monto debe ser un número entero", + "ar": "يجب أن يكون المبلغ رقمًا صحيحًا", + "fr": "Le montant doit être un nombre entier", + "tr": "Tutar tam sayı olmalıdır", + "de": "Der Betrag muss eine ganze Zahl sein", + "uk": "Сума повинна бути цілим числом" + }, "GIT$BITBUCKET_TOKEN_HELP_LINK": { "en": "Bitbucket token help link", "ja": "Bitbucketトークンヘルプリンク", @@ -12483,6 +12579,22 @@ "de": "Organisationen", "uk": "Організації" }, + "COMMON$ORGANIZATION": { + "en": "Organization", + "ja": "組織", + "zh-CN": "组织", + "zh-TW": "組織", + "ko-KR": "조직", + "no": "Organisasjon", + "it": "Organizzazione", + "pt": "Organização", + "es": "Organización", + "ar": "المؤسسة", + "fr": "Organisation", + "tr": "Organizasyon", + "de": "Organisation", + "uk": "Організація" + }, "COMMON$ADD_MICROAGENT": { "en": "Add Microagent", "ja": "マイクロエージェントを追加", @@ -14499,6 +14611,22 @@ "tr": "Kapatmayı Onayla", "uk": "Підтвердити закриття" }, + "ACTION$CONFIRM_UPDATE": { + "en": "Confirm Update", + "ja": "更新を確認", + "zh-CN": "确认更新", + "zh-TW": "確認更新", + "ko-KR": "업데이트 확인", + "fr": "Confirmer la mise à jour", + "es": "Confirmar actualización", + "de": "Aktualisierung bestätigen", + "it": "Conferma aggiornamento", + "pt": "Confirmar atualização", + "ar": "تأكيد التحديث", + "no": "Bekreft oppdatering", + "tr": "Güncellemeyi Onayla", + "uk": "Підтвердити оновлення" + }, "AGENT_STATUS$AGENT_STOPPED": { "en": "Agent stopped", "ja": "エージェントが停止しました。", @@ -16083,6 +16211,646 @@ "de": "Fähigkeit bereit", "uk": "Навичка готова" }, + "ORG$ORGANIZATION_NAME": { + "en": "Organization Name", + "ja": "組織名", + "zh-CN": "组织名称", + "zh-TW": "組織名稱", + "ko-KR": "조직 이름", + "no": "Organisasjonsnavn", + "it": "Nome organizzazione", + "pt": "Nome da organização", + "es": "Nombre de la organización", + "ar": "اسم المنظمة", + "fr": "Nom de l'organisation", + "tr": "Organizasyon Adı", + "de": "Organisationsname", + "uk": "Назва організації" + }, + "ORG$NEXT": { + "en": "Next", + "ja": "次へ", + "zh-CN": "下一步", + "zh-TW": "下一步", + "ko-KR": "다음", + "no": "Neste", + "it": "Avanti", + "pt": "Próximo", + "es": "Siguiente", + "ar": "التالي", + "fr": "Suivant", + "tr": "İleri", + "de": "Weiter", + "uk": "Далі" + }, + "ORG$INVITE_USERS_DESCRIPTION": { + "en": "Invite colleagues using their email address", + "ja": "メールアドレスを使用して同僚を招待", + "zh-CN": "使用电子邮件地址邀请同事", + "zh-TW": "使用電子郵件地址邀請同事", + "ko-KR": "이메일 주소로 동료 초대", + "no": "Inviter kolleger med e-postadresse", + "it": "Invita colleghi usando il loro indirizzo email", + "pt": "Convide colegas usando o endereço de email", + "es": "Invita a colegas usando su dirección de correo", + "ar": "دعوة الزملاء باستخدام عنوان بريدهم الإلكتروني", + "fr": "Invitez des collègues en utilisant leur adresse email", + "tr": "E-posta adresi kullanarak meslektaşlarını davet et", + "de": "Laden Sie Kollegen per E-Mail-Adresse ein", + "uk": "Запросіть колег за їхньою електронною адресою" + }, + "ORG$EMAILS": { + "en": "Emails", + "ja": "メール", + "zh-CN": "电子邮件", + "zh-TW": "電子郵件", + "ko-KR": "이메일", + "no": "E-poster", + "it": "Email", + "pt": "E-mails", + "es": "Correos electrónicos", + "ar": "رسائل البريد الإلكتروني", + "fr": "E-mails", + "tr": "E-postalar", + "de": "E-Mails", + "uk": "Електронні листи" + }, + "ORG$STATUS_INVITED": { + "en": "invited", + "ja": "招待済み", + "zh-CN": "已邀请", + "zh-TW": "已邀請", + "ko-KR": "초대됨", + "no": "invitert", + "it": "invitato", + "pt": "convidado", + "es": "invitado", + "ar": "تمت الدعوة", + "fr": "invité", + "tr": "davet edildi", + "de": "eingeladen", + "uk": "запрошений" + }, + "ORG$ROLE_ADMIN": { + "en": "admin", + "ja": "管理者", + "zh-CN": "管理员", + "zh-TW": "管理員", + "ko-KR": "관리자", + "no": "admin", + "it": "admin", + "pt": "admin", + "es": "admin", + "ar": "مدير", + "fr": "admin", + "tr": "yönetici", + "de": "Admin", + "uk": "адміністратор" + }, + "ORG$ROLE_MEMBER": { + "en": "member", + "ja": "メンバー", + "zh-CN": "成员", + "zh-TW": "成員", + "ko-KR": "멤버", + "no": "medlem", + "it": "membro", + "pt": "membro", + "es": "miembro", + "ar": "عضو", + "fr": "membre", + "tr": "üye", + "de": "Mitglied", + "uk": "учасник" + }, + "ORG$ROLE_OWNER": { + "en": "owner", + "ja": "所有者", + "zh-CN": "所有者", + "zh-TW": "所有者", + "ko-KR": "소유자", + "no": "eier", + "it": "proprietario", + "pt": "proprietário", + "es": "propietario", + "ar": "المالك", + "fr": "propriétaire", + "tr": "sahip", + "de": "Eigentümer", + "uk": "власник" + }, + "ORG$REMOVE": { + "en": "remove", + "ja": "削除", + "zh-CN": "移除", + "zh-TW": "移除", + "ko-KR": "제거", + "no": "fjern", + "it": "rimuovi", + "pt": "remover", + "es": "eliminar", + "ar": "إزالة", + "fr": "supprimer", + "tr": "kaldır", + "de": "entfernen", + "uk": "видалити" + }, + "ORG$CONFIRM_REMOVE_MEMBER": { + "en": "Confirm Remove Member", + "ja": "メンバー削除の確認", + "zh-CN": "确认移除成员", + "zh-TW": "確認移除成員", + "ko-KR": "멤버 제거 확인", + "no": "Bekreft fjerning av medlem", + "it": "Conferma rimozione membro", + "pt": "Confirmar remoção de membro", + "es": "Confirmar eliminación de miembro", + "ar": "تأكيد إزالة العضو", + "fr": "Confirmer la suppression du membre", + "tr": "Üye kaldırma onayı", + "de": "Mitglied entfernen bestätigen", + "uk": "Підтвердити видалення учасника" + }, + "ORG$REMOVE_MEMBER_WARNING": { + "en": "Are you sure you want to remove {{email}} from this organization? This action cannot be undone.", + "ja": "{{email}} をこの組織から削除してもよろしいですか?この操作は元に戻せません。", + "zh-CN": "您确定要将 {{email}} 从此组织中移除吗?此操作无法撤消。", + "zh-TW": "您確定要將 {{email}} 從此組織中移除嗎?此操作無法撤消。", + "ko-KR": "이 조직에서 {{email}}을(를) 제거하시겠습니까? 이 작업은 취소할 수 없습니다.", + "no": "Er du sikker på at du vil fjerne {{email}} fra denne organisasjonen? Denne handlingen kan ikke angres.", + "it": "Sei sicuro di voler rimuovere {{email}} da questa organizzazione? Questa azione non può essere annullata.", + "pt": "Tem certeza de que deseja remover {{email}} desta organização? Esta ação não pode ser desfeita.", + "es": "¿Está seguro de que desea eliminar a {{email}} de esta organización? Esta acción no se puede deshacer.", + "ar": "هل أنت متأكد من أنك تريد إزالة {{email}} من هذه المنظمة؟ لا يمكن التراجع عن هذا الإجراء.", + "fr": "Êtes-vous sûr de vouloir supprimer {{email}} de cette organisation ? Cette action ne peut pas être annulée.", + "tr": "{{email}} kullanıcısını bu organizasyondan kaldırmak istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "de": "Sind Sie sicher, dass Sie {{email}} aus dieser Organisation entfernen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "uk": "Ви впевнені, що хочете видалити {{email}} з цієї організації? Цю дію неможливо скасувати." + }, + "ORG$REMOVE_MEMBER_ERROR": { + "en": "Failed to remove member from organization. Please try again.", + "ja": "組織からメンバーを削除できませんでした。もう一度お試しください。", + "zh-CN": "无法从组织中移除成员。请重试。", + "zh-TW": "無法從組織中移除成員。請重試。", + "ko-KR": "조직에서 멤버를 제거하지 못했습니다. 다시 시도해 주세요.", + "no": "Kunne ikke fjerne medlem fra organisasjonen. Vennligst prøv igjen.", + "it": "Impossibile rimuovere il membro dall'organizzazione. Riprova.", + "pt": "Falha ao remover membro da organização. Por favor, tente novamente.", + "es": "No se pudo eliminar el miembro de la organización. Por favor, inténtelo de nuevo.", + "ar": "فشل في إزالة العضو من المنظمة. يرجى المحاولة مرة أخرى.", + "fr": "Échec de la suppression du membre de l'organisation. Veuillez réessayer.", + "tr": "Üye organizasyondan kaldırılamadı. Lütfen tekrar deneyin.", + "de": "Mitglied konnte nicht aus der Organisation entfernt werden. Bitte versuchen Sie es erneut.", + "uk": "Не вдалося видалити учасника з організації. Будь ласка, спробуйте ще раз." + }, + "ORG$REMOVE_MEMBER_SUCCESS": { + "en": "Member removed successfully", + "ja": "メンバーを削除しました", + "zh-CN": "成员已成功移除", + "zh-TW": "成員已成功移除", + "ko-KR": "멤버가 성공적으로 제거되었습니다", + "no": "Medlem fjernet", + "it": "Membro rimosso con successo", + "pt": "Membro removido com sucesso", + "es": "Miembro eliminado correctamente", + "ar": "تمت إزالة العضو بنجاح", + "fr": "Membre supprimé avec succès", + "tr": "Üye başarıyla kaldırıldı", + "de": "Mitglied erfolgreich entfernt", + "uk": "Учасника успішно видалено" + }, + "ORG$CONFIRM_UPDATE_ROLE": { + "en": "Confirm Role Update", + "ja": "役割の更新を確認", + "zh-CN": "确认更新角色", + "zh-TW": "確認更新角色", + "ko-KR": "역할 업데이트 확인", + "no": "Bekreft rolleoppdatering", + "it": "Conferma aggiornamento ruolo", + "pt": "Confirmar atualização de função", + "es": "Confirmar actualización de rol", + "ar": "تأكيد تحديث الدور", + "fr": "Confirmer la mise à jour du rôle", + "tr": "Rol güncellemesini onayla", + "de": "Rollenaktualisierung bestätigen", + "uk": "Підтвердити оновлення ролі" + }, + "ORG$UPDATE_ROLE_WARNING": { + "en": "Are you sure you want to change the role of {{email}} to {{role}}?", + "ja": "{{email}} の役割を {{role}} に変更してもよろしいですか?", + "zh-CN": "您确定要将 {{email}} 的角色更改为 {{role}} 吗?", + "zh-TW": "您確定要將 {{email}} 的角色更改為 {{role}} 嗎?", + "ko-KR": "{{email}}의 역할을 {{role}}(으)로 변경하시겠습니까?", + "no": "Er du sikker på at du vil endre rollen til {{email}} til {{role}}?", + "it": "Sei sicuro di voler cambiare il ruolo di {{email}} in {{role}}?", + "pt": "Tem certeza de que deseja alterar a função de {{email}} para {{role}}?", + "es": "¿Está seguro de que desea cambiar el rol de {{email}} a {{role}}?", + "ar": "هل أنت متأكد من أنك تريد تغيير دور {{email}} إلى {{role}}؟", + "fr": "Êtes-vous sûr de vouloir changer le rôle de {{email}} en {{role}} ?", + "tr": "{{email}} kullanıcısının rolünü {{role}} olarak değiştirmek istediğinizden emin misiniz?", + "de": "Sind Sie sicher, dass Sie die Rolle von {{email}} auf {{role}} ändern möchten?", + "uk": "Ви впевнені, що хочете змінити роль {{email}} на {{role}}?" + }, + "ORG$UPDATE_ROLE_SUCCESS": { + "en": "Role updated successfully", + "ja": "役割を更新しました", + "zh-CN": "角色已成功更新", + "zh-TW": "角色已成功更新", + "ko-KR": "역할이 성공적으로 업데이트되었습니다", + "no": "Rolle oppdatert", + "it": "Ruolo aggiornato con successo", + "pt": "Função atualizada com sucesso", + "es": "Rol actualizado correctamente", + "ar": "تم تحديث الدور بنجاح", + "fr": "Rôle mis à jour avec succès", + "tr": "Rol başarıyla güncellendi", + "de": "Rolle erfolgreich aktualisiert", + "uk": "Роль успішно оновлено" + }, + "ORG$UPDATE_ROLE_ERROR": { + "en": "Failed to update role. Please try again.", + "ja": "役割の更新に失敗しました。もう一度お試しください。", + "zh-CN": "更新角色失败。请重试。", + "zh-TW": "更新角色失敗。請重試。", + "ko-KR": "역할 업데이트에 실패했습니다. 다시 시도해 주세요.", + "no": "Kunne ikke oppdatere rolle. Vennligst prøv igjen.", + "it": "Impossibile aggiornare il ruolo. Riprova.", + "pt": "Falha ao atualizar função. Por favor, tente novamente.", + "es": "No se pudo actualizar el rol. Por favor, inténtelo de nuevo.", + "ar": "فشل في تحديث الدور. يرجى المحاولة مرة أخرى.", + "fr": "Échec de la mise à jour du rôle. Veuillez réessayer.", + "tr": "Rol güncellenemedi. Lütfen tekrar deneyin.", + "de": "Rolle konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.", + "uk": "Не вдалося оновити роль. Будь ласка, спробуйте ще раз." + }, + "ORG$INVITE_MEMBERS_SUCCESS": { + "en": "Invitations sent successfully", + "ja": "招待を送信しました", + "zh-CN": "邀请发送成功", + "zh-TW": "邀請發送成功", + "ko-KR": "초대가 성공적으로 전송되었습니다", + "no": "Invitasjoner sendt", + "it": "Inviti inviati con successo", + "pt": "Convites enviados com sucesso", + "es": "Invitaciones enviadas correctamente", + "ar": "تم إرسال الدعوات بنجاح", + "fr": "Invitations envoyées avec succès", + "tr": "Davetler başarıyla gönderildi", + "de": "Einladungen erfolgreich gesendet", + "uk": "Запрошення успішно надіслано" + }, + "ORG$INVITE_MEMBERS_ERROR": { + "en": "Failed to send invitations. Please try again.", + "ja": "招待の送信に失敗しました。もう一度お試しください。", + "zh-CN": "发送邀请失败。请重试。", + "zh-TW": "發送邀請失敗。請重試。", + "ko-KR": "초대 전송에 실패했습니다. 다시 시도해 주세요.", + "no": "Kunne ikke sende invitasjoner. Vennligst prøv igjen.", + "it": "Impossibile inviare gli inviti. Riprova.", + "pt": "Falha ao enviar convites. Por favor, tente novamente.", + "es": "No se pudieron enviar las invitaciones. Por favor, inténtelo de nuevo.", + "ar": "فشل في إرسال الدعوات. يرجى المحاولة مرة أخرى.", + "fr": "Échec de l'envoi des invitations. Veuillez réessayer.", + "tr": "Davetler gönderilemedi. Lütfen tekrar deneyin.", + "de": "Einladungen konnten nicht gesendet werden. Bitte versuchen Sie es erneut.", + "uk": "Не вдалося надіслати запрошення. Будь ласка, спробуйте ще раз." + }, + "ORG$DUPLICATE_EMAILS_ERROR": { + "en": "Duplicate email addresses are not allowed", + "ja": "重複するメールアドレスは許可されていません", + "zh-CN": "不允许重复的电子邮件地址", + "zh-TW": "不允許重複的電子郵件地址", + "ko-KR": "중복된 이메일 주소는 허용되지 않습니다", + "no": "Dupliserte e-postadresser er ikke tillatt", + "it": "Gli indirizzi email duplicati non sono consentiti", + "pt": "Endereços de e-mail duplicados não são permitidos", + "es": "No se permiten direcciones de correo electrónico duplicadas", + "ar": "لا يُسمح بعناوين البريد الإلكتروني المكررة", + "fr": "Les adresses e-mail en double ne sont pas autorisées", + "tr": "Yinelenen e-posta adreslerine izin verilmiyor", + "de": "Doppelte E-Mail-Adressen sind nicht erlaubt", + "uk": "Дублікати електронних адрес не допускаються" + }, + "ORG$NO_EMAILS_ADDED_HINT": { + "en": "Please type emails and then press space.", + "ja": "メールアドレスを入力してからスペースを押してください。", + "zh-CN": "请输入邮箱然后按空格键。", + "zh-TW": "請輸入電子郵件然後按空白鍵。", + "ko-KR": "이메일을 입력한 후 스페이스바를 눌러주세요.", + "no": "Skriv inn e-post og trykk mellomrom.", + "it": "Digita le email e poi premi spazio.", + "pt": "Digite os e-mails e pressione espaço.", + "es": "Escriba los correos electrónicos y luego presione espacio.", + "ar": "يرجى كتابة البريد الإلكتروني ثم الضغط على مفتاح المسافة.", + "fr": "Veuillez saisir les e-mails puis appuyer sur espace.", + "tr": "Lütfen e-postaları yazın ve ardından boşluk tuşuna basın.", + "de": "Bitte geben Sie E-Mails ein und drücken Sie dann die Leertaste.", + "uk": "Будь ласка, введіть електронні адреси та натисніть пробіл." + }, + "ORG$ACCOUNT": { + "en": "Account", + "ja": "アカウント", + "zh-CN": "账户", + "zh-TW": "帳戶", + "ko-KR": "계정", + "no": "Konto", + "it": "Account", + "pt": "Conta", + "es": "Cuenta", + "ar": "الحساب", + "fr": "Compte", + "tr": "Hesap", + "de": "Konto", + "uk": "Обліковий запис" + }, + "ORG$INVITE_TEAM": { + "en": "Invite Team", + "ja": "チームを招待", + "zh-CN": "邀请团队", + "zh-TW": "邀請團隊", + "ko-KR": "팀 초대", + "no": "Inviter team", + "it": "Invita team", + "pt": "Convidar equipe", + "es": "Invitar equipo", + "ar": "دعوة الفريق", + "fr": "Inviter l'équipe", + "tr": "Takım Davet Et", + "de": "Team einladen", + "uk": "Запросити команду" + }, + "ORG$MANAGE_TEAM": { + "en": "Manage Team", + "ja": "チーム管理", + "zh-CN": "管理团队", + "zh-TW": "管理團隊", + "ko-KR": "팀 관리", + "no": "Administrer team", + "it": "Gestisci team", + "pt": "Gerenciar equipe", + "es": "Administrar equipo", + "ar": "إدارة الفريق", + "fr": "Gérer l'équipe", + "tr": "Takımı Yönet", + "de": "Team verwalten", + "uk": "Керувати командою" + }, + "ORG$CHANGE_ORG_NAME": { + "en": "Change Organization Name", + "ja": "組織名を変更", + "zh-CN": "更改组织名称", + "zh-TW": "更改組織名稱", + "ko-KR": "조직 이름 변경", + "no": "Endre organisasjonsnavn", + "it": "Cambia nome dell'organizzazione", + "pt": "Alterar nome da organização", + "es": "Cambiar nombre de la organización", + "ar": "تغيير اسم المنظمة", + "fr": "Changer le nom de l'organisation", + "tr": "Organizasyon Adını Değiştir", + "de": "Organisationsnamen ändern", + "uk": "Змінити назву організації" + }, + "ORG$MODIFY_ORG_NAME_DESCRIPTION": { + "en": "Modify your Organization Name and Save", + "ja": "組織名を変更して保存します", + "zh-CN": "修改你的组织名称并保存", + "zh-TW": "修改您的組織名稱並儲存", + "ko-KR": "조직 이름을 수정하고 저장하세요", + "no": "Endre organisasjonsnavnet ditt og lagre", + "it": "Modifica il nome della tua organizzazione e salva", + "pt": "Modifique o nome da sua organização e salve", + "es": "Modifica el nombre de tu organización y guarda", + "ar": "قم بتعديل اسم المنظمة الخاصة بك وحفظه", + "fr": "Modifiez le nom de votre organisation et enregistrez", + "tr": "Organizasyon adınızı değiştirin ve kaydedin", + "de": "Ändern Sie den Namen Ihrer Organisation und speichern Sie ihn", + "uk": "Змініть назву вашої організації та збережіть" + }, + "ORG$ADD_CREDITS": { + "en": "Add Credits", + "ja": "クレジットを追加", + "zh-CN": "添加积分", + "zh-TW": "新增點數", + "ko-KR": "크레딧 추가", + "no": "Legg til kreditter", + "it": "Aggiungi crediti", + "pt": "Adicionar créditos", + "es": "Añadir créditos", + "ar": "إضافة رصيد", + "fr": "Ajouter des crédits", + "tr": "Kredi Ekle", + "de": "Credits hinzufügen", + "uk": "Додати кредити" + }, + "ORG$CREDITS": { + "en": "Credits", + "ja": "クレジット", + "zh-CN": "积分", + "zh-TW": "點數", + "ko-KR": "크레딧", + "no": "Kreditter", + "it": "Crediti", + "pt": "Créditos", + "es": "Créditos", + "ar": "الرصيد", + "fr": "Crédits", + "tr": "Krediler", + "de": "Credits", + "uk": "Кредити" + }, + "ORG$ADD": { + "en": "+ Add", + "ja": "+ 追加", + "zh-CN": "+ 添加", + "zh-TW": "+ 新增", + "ko-KR": "+ 추가", + "no": "+ Legg til", + "it": "+ Aggiungi", + "pt": "+ Adicionar", + "es": "+ Añadir", + "ar": "+ إضافة", + "fr": "+ Ajouter", + "tr": "+ Ekle", + "de": "+ Hinzufügen", + "uk": "+ Додати" + }, + "ORG$BILLING_INFORMATION": { + "en": "Billing Information", + "ja": "請求情報", + "zh-CN": "账单信息", + "zh-TW": "帳單資訊", + "ko-KR": "결제 정보", + "no": "Faktureringsinformasjon", + "it": "Informazioni di fatturazione", + "pt": "Informações de cobrança", + "es": "Información de facturación", + "ar": "معلومات الفوترة", + "fr": "Informations de facturation", + "tr": "Fatura Bilgisi", + "de": "Rechnungsinformationen", + "uk": "Платіжна інформація" + }, + "ORG$CHANGE": { + "en": "Change", + "ja": "変更", + "zh-CN": "更改", + "zh-TW": "變更", + "ko-KR": "변경", + "no": "Endre", + "it": "Modifica", + "pt": "Alterar", + "es": "Cambiar", + "ar": "تغيير", + "fr": "Modifier", + "tr": "Değiştir", + "de": "Ändern", + "uk": "Змінити" + }, + "ORG$DELETE_ORGANIZATION": { + "en": "Delete Organization", + "ja": "組織を削除", + "zh-CN": "删除组织", + "zh-TW": "刪除組織", + "ko-KR": "조직 삭제", + "no": "Slett organisasjon", + "it": "Elimina organizzazione", + "pt": "Excluir organização", + "es": "Eliminar organización", + "ar": "حذف المنظمة", + "fr": "Supprimer l'organisation", + "tr": "Organizasyonu Sil", + "de": "Organisation löschen", + "uk": "Видалити організацію" + }, + "ORG$DELETE_ORGANIZATION_WARNING": { + "en": "Are you sure you want to delete this organization? This action cannot be undone.", + "ja": "この組織を削除してもよろしいですか?この操作は元に戻せません。", + "zh-CN": "您确定要删除此组织吗?此操作无法撤消。", + "zh-TW": "您確定要刪除此組織嗎?此操作無法撤銷。", + "ko-KR": "이 조직을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "no": "Er du sikker på at du vil slette denne organisasjonen? Denne handlingen kan ikke angres.", + "it": "Sei sicuro di voler eliminare questa organizzazione? Questa azione non può essere annullata.", + "pt": "Tem certeza de que deseja excluir esta organização? Esta ação não pode ser desfeita.", + "es": "¿Está seguro de que desea eliminar esta organización? Esta acción no se puede deshacer.", + "ar": "هل أنت متأكد من أنك تريد حذف هذه المنظمة؟ لا يمكن التراجع عن هذا الإجراء.", + "fr": "Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action ne peut pas être annulée.", + "tr": "Bu organizasyonu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "de": "Sind Sie sicher, dass Sie diese Organisation löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "uk": "Ви впевнені, що хочете видалити цю організацію? Цю дію не можна скасувати." + }, + "ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME": { + "en": "Are you sure you want to delete the \"{{name}}\" organization? This action cannot be undone.", + "ja": "「{{name}}」組織を削除してもよろしいですか?この操作は元に戻せません。", + "zh-CN": "您确定要删除\"{{name}}\"组织吗?此操作无法撤消。", + "zh-TW": "您確定要刪除「{{name}}」組織嗎?此操作無法撤銷。", + "ko-KR": "\"{{name}}\" 조직을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "no": "Er du sikker på at du vil slette organisasjonen \"{{name}}\"? Denne handlingen kan ikke angres.", + "it": "Sei sicuro di voler eliminare l'organizzazione \"{{name}}\"? Questa azione non può essere annullata.", + "pt": "Tem certeza de que deseja excluir a organização \"{{name}}\"? Esta ação não pode ser desfeita.", + "es": "¿Está seguro de que desea eliminar la organización \"{{name}}\"? Esta acción no se puede deshacer.", + "ar": "هل أنت متأكد من أنك تريد حذف المنظمة \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.", + "fr": "Êtes-vous sûr de vouloir supprimer l'organisation \\u00AB {{name}} \\u00BB ? Cette action ne peut pas être annulée.", + "tr": "\"{{name}}\" organizasyonunu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "de": "Sind Sie sicher, dass Sie die Organisation \\u201E{{name}}\\u201C löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "uk": "Ви впевнені, що хочете видалити організацію \\u00AB{{name}}\\u00BB? Цю дію не можна скасувати." + }, + "ORG$DELETE_ORGANIZATION_ERROR": { + "en": "Failed to delete organization", + "ja": "組織の削除に失敗しました", + "zh-CN": "删除组织失败", + "zh-TW": "刪除組織失敗", + "ko-KR": "조직 삭제에 실패했습니다", + "no": "Kunne ikke slette organisasjonen", + "it": "Impossibile eliminare l'organizzazione", + "pt": "Falha ao excluir a organização", + "es": "Error al eliminar la organización", + "ar": "فشل في حذف المنظمة", + "fr": "Échec de la suppression de l'organisation", + "tr": "Organizasyon silinemedi", + "de": "Organisation konnte nicht gelöscht werden", + "uk": "Не вдалося видалити організацію" + }, + "ACCOUNT_SETTINGS$SETTINGS": { + "en": "Settings", + "ja": "設定", + "zh-CN": "设置", + "zh-TW": "設定", + "ko-KR": "설정", + "no": "Innstillinger", + "it": "Impostazioni", + "pt": "Configurações", + "es": "Configuración", + "ar": "الإعدادات", + "fr": "Paramètres", + "tr": "Ayarlar", + "de": "Einstellungen", + "uk": "Налаштування" + }, + "ORG$MANAGE_ORGANIZATION_MEMBERS": { + "en": "Manage Organization Members", + "ja": "組織メンバーの管理", + "zh-CN": "管理组织成员", + "zh-TW": "管理組織成員", + "ko-KR": "조직 구성원 관리", + "no": "Administrer organisasjonsmedlemmer", + "it": "Gestisci membri dell'organizzazione", + "pt": "Gerenciar membros da organização", + "es": "Gestionar miembros de la organización", + "ar": "إدارة أعضاء المنظمة", + "fr": "Gérer les membres de l'organisation", + "tr": "Organizasyon Üyelerini Yönet", + "de": "Organisationsmitglieder verwalten", + "uk": "Керувати учасниками організації" + }, + "ORG$SELECT_ORGANIZATION_PLACEHOLDER": { + "en": "Please select an organization", + "ja": "組織を選択してください", + "zh-CN": "请选择一个组织", + "zh-TW": "請選擇一個組織", + "ko-KR": "조직을 선택해 주세요", + "no": "Vennligst velg en organisasjon", + "it": "Seleziona un'organizzazione", + "pt": "Por favor, selecione uma organização", + "es": "Por favor, seleccione una organización", + "ar": "يرجى اختيار منظمة", + "fr": "Veuillez sélectionner une organisation", + "tr": "Lütfen bir organizasyon seçin", + "de": "Bitte wählen Sie eine Organisation", + "uk": "Будь ласка, виберіть організацію" + }, + "ORG$PERSONAL_WORKSPACE": { + "en": "Personal Workspace", + "ja": "個人ワークスペース", + "zh-CN": "个人工作区", + "zh-TW": "個人工作區", + "ko-KR": "개인 워크스페이스", + "no": "Personlig arbeidsområde", + "it": "Area di lavoro personale", + "pt": "Área de trabalho pessoal", + "es": "Espacio de trabajo personal", + "ar": "مساحة العمل الشخصية", + "fr": "Espace de travail personnel", + "tr": "Kişisel çalışma alanı", + "de": "Persönlicher Arbeitsbereich", + "uk": "Особистий робочий простір" + }, + "ORG$ENTER_NEW_ORGANIZATION_NAME": { + "en": "Enter new organization name", + "ja": "新しい組織名を入力してください", + "zh-CN": "请输入新的组织名称", + "zh-TW": "請輸入新的組織名稱", + "ko-KR": "새 조직 이름을 입력하세요", + "no": "Skriv inn nytt organisasjonsnavn", + "it": "Inserisci il nuovo nome dell'organizzazione", + "pt": "Digite o novo nome da organização", + "es": "Ingrese el nuevo nombre de la organización", + "ar": "أدخل اسم المنظمة الجديد", + "fr": "Entrez le nouveau nom de l'organisation", + "tr": "Yeni organizasyon adını girin", + "de": "Geben Sie den neuen Organisationsnamen ein", + "uk": "Введіть нову назву організації" + }, "CONVERSATION$SHOW_SKILLS": { "en": "Show Available Skills", "ja": "利用可能なスキルを表示", @@ -16355,6 +17123,150 @@ "de": "Link in die Zwischenablage kopiert", "uk": "Посилання скопійовано в буфер обміну" }, + "COMMON$TYPE_EMAIL_AND_PRESS_SPACE": { + "en": "Type email and press Space", + "ja": "メールアドレスを入力してスペースキーを押してください", + "zh-CN": "输入邮箱并按空格键", + "zh-TW": "輸入電子郵件並按空白鍵", + "ko-KR": "이메일을 입력하고 스페이스바를 누르세요", + "no": "Skriv inn e-post og trykk på mellomromstasten", + "it": "Digita l'e-mail e premi Spazio", + "pt": "Digite o e-mail e pressione Espaço", + "es": "Escribe el correo electrónico y pulsa Espacio", + "ar": "اكتب البريد الإلكتروني واضغط على مفتاح المسافة", + "fr": "Tapez l'e-mail et appuyez sur Espace", + "tr": "E-postu yazıp Boşluk tuşuna basın", + "de": "E-Mail eingeben und Leertaste drücken", + "uk": "Введіть e-mail і натисніть Пробіл" + }, + "ORG$INVITE_ORG_MEMBERS": { + "en": "Invite Organization Members", + "ja": "組織メンバーを招待", + "zh-CN": "邀请组织成员", + "zh-TW": "邀請組織成員", + "ko-KR": "조직 구성원 초대", + "no": "Inviter organisasjonsmedlemmer", + "it": "Invita membri dell'organizzazione", + "pt": "Convidar membros da organização", + "es": "Invitar a miembros de la organización", + "ar": "دعوة أعضاء المنظمة", + "fr": "Inviter des membres de l'organisation", + "tr": "Organizasyon üyelerini davet et", + "de": "Organisationsmitglieder einladen", + "uk": "Запросити членів організації" + }, + "ORG$MANAGE_ORGANIZATION": { + "en": "Manage Organization", + "ja": "組織を管理", + "zh-CN": "管理组织", + "zh-TW": "管理組織", + "ko-KR": "조직 관리", + "no": "Administrer organisasjon", + "it": "Gestisci organizzazione", + "pt": "Gerenciar organização", + "es": "Gestionar organización", + "ar": "إدارة المنظمة", + "fr": "Gérer l'organisation", + "tr": "Organizasyonu yönet", + "de": "Organisation verwalten", + "uk": "Керувати організацією" + }, + "ORG$ORGANIZATION_MEMBERS": { + "en": "Organization Members", + "ja": "組織メンバー", + "zh-CN": "组织成员", + "zh-TW": "組織成員", + "ko-KR": "조직 구성원", + "no": "Organisasjonsmedlemmer", + "it": "Membri dell'organizzazione", + "pt": "Membros da organização", + "es": "Miembros de la organización", + "ar": "أعضاء المنظمة", + "fr": "Membres de l'organisation", + "tr": "Organizasyon Üyeleri", + "de": "Organisationsmitglieder", + "uk": "Члени організації" + }, + "ORG$ALL_ORGANIZATION_MEMBERS": { + "en": "All Organization Members", + "ja": "全ての組織メンバー", + "zh-CN": "所有组织成员", + "zh-TW": "所有組織成員", + "ko-KR": "모든 조직 구성원", + "no": "Alle organisasjonsmedlemmer", + "it": "Tutti i membri dell'organizzazione", + "pt": "Todos os membros da organização", + "es": "Todos los miembros de la organización", + "ar": "جميع أعضاء المنظمة", + "fr": "Tous les membres de l'organisation", + "tr": "Tüm organizasyon üyeleri", + "de": "Alle Organisationsmitglieder", + "uk": "Усі члени організації" + }, + "ORG$SEARCH_BY_EMAIL": { + "en": "Search by email...", + "ja": "メールで検索...", + "zh-CN": "按邮箱搜索...", + "zh-TW": "按電郵搜尋...", + "ko-KR": "이메일로 검색...", + "no": "Søk etter e-post...", + "it": "Cerca per email...", + "pt": "Pesquisar por email...", + "es": "Buscar por correo electrónico...", + "ar": "البحث بالبريد الإلكتروني...", + "fr": "Rechercher par e-mail...", + "tr": "E-posta ile ara...", + "de": "Nach E-Mail suchen...", + "uk": "Пошук за електронною поштою..." + }, + "ORG$NO_MEMBERS_FOUND": { + "en": "No members found", + "ja": "メンバーが見つかりません", + "zh-CN": "未找到成员", + "zh-TW": "未找到成員", + "ko-KR": "멤버를 찾을 수 없습니다", + "no": "Ingen medlemmer funnet", + "it": "Nessun membro trovato", + "pt": "Nenhum membro encontrado", + "es": "No se encontraron miembros", + "ar": "لم يتم العثور على أعضاء", + "fr": "Aucun membre trouvé", + "tr": "Üye bulunamadı", + "de": "Keine Mitglieder gefunden", + "uk": "Членів не знайдено" + }, + "ORG$NO_MEMBERS_MATCHING_FILTER": { + "en": "No members match your search", + "ja": "検索に一致するメンバーはいません", + "zh-CN": "没有符合搜索条件的成员", + "zh-TW": "沒有符合搜尋條件的成員", + "ko-KR": "검색과 일치하는 멤버가 없습니다", + "no": "Ingen medlemmer samsvarer med søket ditt", + "it": "Nessun membro corrisponde alla tua ricerca", + "pt": "Nenhum membro corresponde à sua pesquisa", + "es": "Ningún miembro coincide con tu búsqueda", + "ar": "لا يوجد أعضاء يطابقون بحثك", + "fr": "Aucun membre ne correspond à votre recherche", + "tr": "Aramanızla eşleşen üye bulunamadı", + "de": "Keine Mitglieder entsprechen Ihrer Suche", + "uk": "Жодний член не відповідає вашому пошуку" + }, + "ORG$FAILED_TO_LOAD_MEMBERS": { + "en": "Failed to load members", + "ja": "メンバーの読み込みに失敗しました", + "zh-CN": "加载成员失败", + "zh-TW": "載入成員失敗", + "ko-KR": "멤버를 불러오지 못했습니다", + "no": "Kunne ikke laste medlemmer", + "it": "Impossibile caricare i membri", + "pt": "Falha ao carregar membros", + "es": "Error al cargar miembros", + "ar": "فشل تحميل الأعضاء", + "fr": "Échec du chargement des membres", + "tr": "Üyeler yüklenemedi", + "de": "Mitglieder konnten nicht geladen werden", + "uk": "Не вдалося завантажити членів" + }, "ONBOARDING$STEP1_TITLE": { "en": "What's your role?", "ja": "あなたの役割は?", diff --git a/frontend/src/icons/admin.svg b/frontend/src/icons/admin.svg new file mode 100644 index 0000000000..89004b4913 --- /dev/null +++ b/frontend/src/icons/admin.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/icons/loading-outer.svg b/frontend/src/icons/loading-outer.svg index aebe42c8e5..4c2d56aff0 100644 --- a/frontend/src/icons/loading-outer.svg +++ b/frontend/src/icons/loading-outer.svg @@ -1,4 +1,4 @@ + stroke="currentColor" stroke-width="6" stroke-linecap="round" /> diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 6936b283e9..64a8574c14 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -3,6 +3,7 @@ import { BILLING_HANDLERS } from "./billing-handlers"; import { FILE_SERVICE_HANDLERS } from "./file-service-handlers"; import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers"; import { SECRETS_HANDLERS } from "./secrets-handlers"; +import { ORG_HANDLERS } from "./org-handlers"; import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers"; import { SETTINGS_HANDLERS, @@ -15,6 +16,7 @@ import { FEEDBACK_HANDLERS } from "./feedback-handlers"; import { ANALYTICS_HANDLERS } from "./analytics-handlers"; export const handlers = [ + ...ORG_HANDLERS, ...API_KEYS_HANDLERS, ...BILLING_HANDLERS, ...FILE_SERVICE_HANDLERS, diff --git a/frontend/src/mocks/org-handlers.ts b/frontend/src/mocks/org-handlers.ts new file mode 100644 index 0000000000..9b7cd930ae --- /dev/null +++ b/frontend/src/mocks/org-handlers.ts @@ -0,0 +1,556 @@ +import { http, HttpResponse } from "msw"; +import { + Organization, + OrganizationMember, + OrganizationUserRole, + UpdateOrganizationMemberParams, +} from "#/types/org"; + +const MOCK_ME: Omit = { + user_id: "99", + email: "me@acme.org", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", +}; + +export const createMockOrganization = ( + id: string, + name: string, + credits: number, + is_personal?: boolean, +): Organization => ({ + id, + name, + contact_name: "Contact Name", + contact_email: "contact@example.com", + conversation_expiration: 86400, + agent: "default-agent", + default_max_iterations: 20, + security_analyzer: "standard", + confirmation_mode: false, + default_llm_model: "gpt-5-1", + default_llm_api_key_for_byor: "*********", + default_llm_base_url: "https://api.example-llm.com", + remote_runtime_resource_factor: 2, + enable_default_condenser: true, + billing_margin: 0.15, + enable_proactive_conversation_starters: true, + sandbox_base_container_image: "ghcr.io/example/sandbox-base:latest", + sandbox_runtime_container_image: "ghcr.io/example/sandbox-runtime:latest", + org_version: 0, + mcp_config: { + tools: [], + settings: {}, + }, + search_api_key: null, + sandbox_api_key: null, + max_budget_per_task: 25.0, + enable_solvability_analysis: false, + v1_enabled: true, + credits, + is_personal, +}); + +// Named mock organizations for test convenience +export const MOCK_PERSONAL_ORG = createMockOrganization( + "1", + "Personal Workspace", + 100, + true, +); +export const MOCK_TEAM_ORG_ACME = createMockOrganization( + "2", + "Acme Corp", + 1000, +); +export const MOCK_TEAM_ORG_BETA = createMockOrganization("3", "Beta LLC", 500); +export const MOCK_TEAM_ORG_ALLHANDS = createMockOrganization( + "4", + "All Hands AI", + 750, +); + +export const INITIAL_MOCK_ORGS: Organization[] = [ + MOCK_PERSONAL_ORG, + MOCK_TEAM_ORG_ACME, + MOCK_TEAM_ORG_BETA, + MOCK_TEAM_ORG_ALLHANDS, +]; + +const INITIAL_MOCK_MEMBERS: Record = { + "1": [ + { + org_id: "1", + user_id: "99", + email: "me@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + ], + "2": [ + { + org_id: "2", + user_id: "1", + email: "alice@acme.org", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "1", + user_id: "2", + email: "bob@acme.org", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "1", + user_id: "3", + email: "charlie@acme.org", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + ], + "3": [ + { + org_id: "2", + user_id: "4", + email: "tony@gamma.org", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "2", + user_id: "5", + email: "evan@gamma.org", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + ], + "4": [ + { + org_id: "3", + user_id: "6", + email: "robert@all-hands.dev", + role: "owner", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "3", + user_id: "7", + email: "ray@all-hands.dev", + role: "admin", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "3", + user_id: "8", + email: "chuck@all-hands.dev", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "3", + user_id: "9", + email: "stephan@all-hands.dev", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "active", + }, + { + org_id: "3", + user_id: "10", + email: "tim@all-hands.dev", + role: "member", + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "invited", + }, + ], +}; + +export const ORGS_AND_MEMBERS: Record = { + "1": INITIAL_MOCK_MEMBERS["1"].map((member) => ({ ...member })), + "2": INITIAL_MOCK_MEMBERS["2"].map((member) => ({ ...member })), + "3": INITIAL_MOCK_MEMBERS["3"].map((member) => ({ ...member })), + "4": INITIAL_MOCK_MEMBERS["4"].map((member) => ({ ...member })), +}; + +const orgs = new Map(INITIAL_MOCK_ORGS.map((org) => [org.id, org])); + +export const resetOrgMockData = () => { + // Reset organizations to initial state + orgs.clear(); + INITIAL_MOCK_ORGS.forEach((org) => { + orgs.set(org.id, { ...org }); + }); +}; + +export const resetOrgsAndMembersMockData = () => { + // Reset ORGS_AND_MEMBERS to initial state + // Note: This is needed since ORGS_AND_MEMBERS is mutated by updateMember + Object.keys(INITIAL_MOCK_MEMBERS).forEach((orgId) => { + ORGS_AND_MEMBERS[orgId] = INITIAL_MOCK_MEMBERS[orgId].map((member) => ({ + ...member, + })); + }); +}; + +export const ORG_HANDLERS = [ + http.get("/api/organizations/:orgId/me", ({ params }) => { + const orgId = params.orgId?.toString(); + if (!orgId || !ORGS_AND_MEMBERS[orgId]) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + let role: OrganizationUserRole = "member"; + switch (orgId) { + case "1": // Personal Workspace + role = "owner"; + break; + case "2": // Acme Corp + role = "owner"; + break; + case "3": // Beta LLC + role = "member"; + break; + case "4": // All Hands AI + role = "admin"; + break; + default: + role = "member"; + } + + const me: OrganizationMember = { + ...MOCK_ME, + org_id: orgId, + role, + }; + return HttpResponse.json(me); + }), + + http.get("/api/organizations/:orgId/members", ({ params, request }) => { + const orgId = params.orgId?.toString(); + if (!orgId || !ORGS_AND_MEMBERS[orgId]) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + // Parse query parameters + const url = new URL(request.url); + const pageIdParam = url.searchParams.get("page_id"); + const limitParam = url.searchParams.get("limit"); + const emailFilter = url.searchParams.get("email"); + + const offset = pageIdParam ? parseInt(pageIdParam, 10) : 0; + const limit = limitParam ? parseInt(limitParam, 10) : 10; + + let members = ORGS_AND_MEMBERS[orgId]; + + // Apply email filter if provided + if (emailFilter) { + members = members.filter((member) => + member.email.toLowerCase().includes(emailFilter.toLowerCase()), + ); + } + + const paginatedMembers = members.slice(offset, offset + limit); + const currentPage = Math.floor(offset / limit) + 1; + + return HttpResponse.json({ + items: paginatedMembers, + current_page: currentPage, + per_page: limit, + }); + }), + + http.get("/api/organizations/:orgId/members/count", ({ params, request }) => { + const orgId = params.orgId?.toString(); + if (!orgId || !ORGS_AND_MEMBERS[orgId]) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + // Parse query parameters + const url = new URL(request.url); + const emailFilter = url.searchParams.get("email"); + + let members = ORGS_AND_MEMBERS[orgId]; + + // Apply email filter if provided + if (emailFilter) { + members = members.filter((member) => + member.email.toLowerCase().includes(emailFilter.toLowerCase()), + ); + } + + return HttpResponse.json(members.length); + }), + + http.get("/api/organizations", () => { + const organizations = Array.from(orgs.values()); + // Return the first org as the current org for mock purposes + const currentOrgId = organizations.length > 0 ? organizations[0].id : null; + return HttpResponse.json({ + items: organizations, + current_org_id: currentOrgId, + }); + }), + + http.patch("/api/organizations/:orgId", async ({ request, params }) => { + const { name } = (await request.json()) as { + name: string; + }; + const orgId = params.orgId?.toString(); + + if (!name) { + return HttpResponse.json({ error: "Name is required" }, { status: 400 }); + } + + if (!orgId) { + return HttpResponse.json( + { error: "Organization ID is required" }, + { status: 400 }, + ); + } + + const existingOrg = orgs.get(orgId); + if (!existingOrg) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + const updatedOrg: Organization = { + ...existingOrg, + name, + }; + orgs.set(orgId, updatedOrg); + + return HttpResponse.json(updatedOrg, { status: 201 }); + }), + + http.get("/api/organizations/:orgId", ({ params }) => { + const orgId = params.orgId?.toString(); + + if (orgId) { + const org = orgs.get(orgId); + if (org) return HttpResponse.json(org); + } + + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + }), + + http.delete("/api/organizations/:orgId", ({ params }) => { + const orgId = params.orgId?.toString(); + + if (orgId && orgs.has(orgId) && ORGS_AND_MEMBERS[orgId]) { + orgs.delete(orgId); + delete ORGS_AND_MEMBERS[orgId]; + return HttpResponse.json( + { message: "Organization deleted" }, + { status: 204 }, + ); + } + + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + }), + + http.get("/api/organizations/:orgId/payment", ({ params }) => { + const orgId = params.orgId?.toString(); + + if (orgId) { + const org = orgs.get(orgId); + if (org) { + return HttpResponse.json({ + cardNumber: "**** **** **** 1234", // Mocked payment info + }); + } + } + + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + }), + + http.patch( + "/api/organizations/:orgId/members/:userId", + async ({ request, params }) => { + const updateData = + (await request.json()) as UpdateOrganizationMemberParams; + const orgId = params.orgId?.toString(); + const userId = params.userId?.toString(); + + if (!orgId || !ORGS_AND_MEMBERS[orgId]) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + const member = ORGS_AND_MEMBERS[orgId].find((m) => m.user_id === userId); + if (!member) { + return HttpResponse.json( + { error: "Member not found" }, + { status: 404 }, + ); + } + + // Update member with any provided fields + const newMember: OrganizationMember = { + ...member, + ...updateData, + }; + const newMembers = ORGS_AND_MEMBERS[orgId].map((m) => + m.user_id === userId ? newMember : m, + ); + ORGS_AND_MEMBERS[orgId] = newMembers; + + return HttpResponse.json(newMember, { status: 200 }); + }, + ), + + http.delete("/api/organizations/:orgId/members/:userId", ({ params }) => { + const { orgId, userId } = params; + + if (!orgId || !userId || !ORGS_AND_MEMBERS[orgId as string]) { + return HttpResponse.json( + { error: "Organization or member not found" }, + { status: 404 }, + ); + } + + // Remove member from organization + const members = ORGS_AND_MEMBERS[orgId as string]; + const updatedMembers = members.filter( + (member) => member.user_id !== userId, + ); + ORGS_AND_MEMBERS[orgId as string] = updatedMembers; + + return HttpResponse.json({ message: "Member removed" }, { status: 200 }); + }), + + http.post("/api/organizations/:orgId/switch", ({ params }) => { + const orgId = params.orgId?.toString(); + + if (orgId) { + const org = orgs.get(orgId); + if (org) return HttpResponse.json(org); + } + + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + }), + + http.post( + "/api/organizations/:orgId/members/invite", + async ({ request, params }) => { + const { emails } = (await request.json()) as { emails: string[] }; + const orgId = params.orgId?.toString(); + + if (!emails || emails.length === 0) { + return HttpResponse.json( + { error: "Emails are required" }, + { status: 400 }, + ); + } + + if (!orgId || !ORGS_AND_MEMBERS[orgId]) { + return HttpResponse.json( + { error: "Organization not found" }, + { status: 404 }, + ); + } + + const members = Array.from(ORGS_AND_MEMBERS[orgId]); + const newMembers: OrganizationMember[] = emails.map((email, index) => ({ + org_id: orgId, + user_id: String(members.length + index + 1), + email, + role: "member" as const, + llm_api_key: "**********", + max_iterations: 20, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "https://api.openai.com", + status: "invited" as const, + })); + + ORGS_AND_MEMBERS[orgId] = [...members, ...newMembers]; + + return HttpResponse.json(newMembers, { status: 201 }); + }, + ), +]; diff --git a/frontend/src/mocks/settings-handlers.ts b/frontend/src/mocks/settings-handlers.ts index 8534789831..19079e3655 100644 --- a/frontend/src/mocks/settings-handlers.ts +++ b/frontend/src/mocks/settings-handlers.ts @@ -3,6 +3,37 @@ import { WebClientConfig } from "#/api/option-service/option.types"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { Provider, Settings } from "#/types/settings"; +/** + * Creates a mock WebClientConfig with all required fields. + * Use this helper to create test config objects with sensible defaults. + */ +export const createMockWebClientConfig = ( + overrides: Partial = {}, +): WebClientConfig => ({ + app_mode: "oss", + posthog_client_key: "test-posthog-key", + feature_flags: { + enable_billing: false, + hide_llm_settings: false, + enable_jira: false, + enable_jira_dc: false, + enable_linear: false, + hide_users_page: false, + hide_billing_page: false, + hide_integrations_page: false, + ...overrides.feature_flags, + }, + providers_configured: [], + maintenance_start_time: null, + auth_url: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: new Date().toISOString(), + github_app_slug: null, + ...overrides, +}); + export const MOCK_DEFAULT_USER_SETTINGS: Settings = { llm_model: DEFAULT_SETTINGS.llm_model, llm_base_url: DEFAULT_SETTINGS.llm_base_url, @@ -73,8 +104,8 @@ export const SETTINGS_HANDLERS = [ app_mode: mockSaas ? "saas" : "oss", posthog_client_key: "fake-posthog-client-key", feature_flags: { - enable_billing: false, - hide_llm_settings: mockSaas, + enable_billing: mockSaas, + hide_llm_settings: false, enable_jira: false, enable_jira_dc: false, enable_linear: false, diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index b50091dc3c..ba401dae9d 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -20,6 +20,8 @@ export default [ route("billing", "routes/billing.tsx"), route("secrets", "routes/secrets-settings.tsx"), route("api-keys", "routes/api-keys.tsx"), + route("org-members", "routes/manage-organization-members.tsx"), + route("org", "routes/manage-org.tsx"), ]), route("conversations/:conversationId", "routes/conversation.tsx"), route("microagent-management", "routes/microagent-management.tsx"), diff --git a/frontend/src/routes/api-keys.tsx b/frontend/src/routes/api-keys.tsx index e5d733ecb7..2bae9a4a9f 100644 --- a/frontend/src/routes/api-keys.tsx +++ b/frontend/src/routes/api-keys.tsx @@ -1,5 +1,8 @@ import React from "react"; import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; + +export const clientLoader = createPermissionGuard("manage_api_keys"); function ApiKeysScreen() { return ( diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx index eafd225bca..8226488468 100644 --- a/frontend/src/routes/app-settings.tsx +++ b/frontend/src/routes/app-settings.tsx @@ -19,6 +19,11 @@ import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message" import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton"; import { useConfig } from "#/hooks/query/use-config"; import { parseMaxBudgetPerTask } from "#/utils/settings-utils"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; + +export const clientLoader = createPermissionGuard( + "manage_application_settings", +); function AppSettingsScreen() { const posthog = usePostHog(); diff --git a/frontend/src/routes/billing.tsx b/frontend/src/routes/billing.tsx index 05d23fe276..195d8eb0c1 100644 --- a/frontend/src/routes/billing.tsx +++ b/frontend/src/routes/billing.tsx @@ -1,4 +1,4 @@ -import { useSearchParams } from "react-router"; +import { redirect, useSearchParams } from "react-router"; import React from "react"; import { useTranslation } from "react-i18next"; import { PaymentForm } from "#/components/features/payment/payment-form"; @@ -8,11 +8,53 @@ import { } from "#/utils/custom-toast-handlers"; import { I18nKey } from "#/i18n/declaration"; import { useTracking } from "#/hooks/use-tracking"; +import { useMe } from "#/hooks/query/use-me"; +import { usePermission } from "#/hooks/organizations/use-permissions"; +import { getActiveOrganizationUser } from "#/utils/org/permission-checks"; +import { rolePermissions } from "#/utils/org/permissions"; +import { isBillingHidden } from "#/utils/org/billing-visibility"; +import { queryClient } from "#/query-client-config"; +import OptionService from "#/api/option-service/option-service.api"; +import { WebClientConfig } from "#/api/option-service/option.types"; +import { getFirstAvailablePath } from "#/utils/settings-utils"; + +export const clientLoader = async () => { + let config = queryClient.getQueryData(["web-client-config"]); + if (!config) { + config = await OptionService.getConfig(); + queryClient.setQueryData(["web-client-config"], config); + } + + const isSaas = config?.app_mode === "saas"; + const featureFlags = config?.feature_flags; + + const getFallbackPath = () => + getFirstAvailablePath(isSaas, featureFlags) ?? "/settings"; + + const user = await getActiveOrganizationUser(); + + if (!user) { + return redirect(getFallbackPath()); + } + + const userRole = user.role ?? "member"; + + if ( + isBillingHidden(config, rolePermissions[userRole].includes("view_billing")) + ) { + return redirect(getFallbackPath()); + } + + return null; +}; function BillingSettingsScreen() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const { trackCreditsPurchased } = useTracking(); + const { data: me } = useMe(); + const { hasPermission } = usePermission(me?.role ?? "member"); + const canAddCredits = !!me && hasPermission("add_credits"); const checkoutStatus = searchParams.get("checkout"); React.useEffect(() => { @@ -38,7 +80,7 @@ function BillingSettingsScreen() { } }, [checkoutStatus, searchParams, setSearchParams, t, trackCreditsPurchased]); - return ; + return ; } export default BillingSettingsScreen; diff --git a/frontend/src/routes/git-settings.tsx b/frontend/src/routes/git-settings.tsx index 1b07e081dc..7061dbe303 100644 --- a/frontend/src/routes/git-settings.tsx +++ b/frontend/src/routes/git-settings.tsx @@ -1,6 +1,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { useConfig } from "#/hooks/query/use-config"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; import { useSettings } from "#/hooks/query/use-settings"; import { BrandButton } from "#/components/features/settings/brand-button"; import { useLogout } from "#/hooks/mutation/use-logout"; @@ -26,6 +27,8 @@ import { useUserProviders } from "#/hooks/use-user-providers"; import { ProjectManagementIntegration } from "#/components/features/settings/project-management/project-management-integration"; import { Typography } from "#/ui/typography"; +export const clientLoader = createPermissionGuard("manage_integrations"); + function GitSettingsScreen() { const { t } = useTranslation(); diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index d28bfa661b..d9489ec35a 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { AxiosError } from "axios"; import { useSearchParams } from "react-router"; import { ModelSelector } from "#/components/shared/modals/settings/model-selector"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers"; import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options"; import { useSettings } from "#/hooks/query/use-settings"; @@ -28,6 +29,8 @@ import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { getProviderId } from "#/utils/map-provider"; import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models"; +import { useMe } from "#/hooks/query/use-me"; +import { usePermission } from "#/hooks/organizations/use-permissions"; interface OpenHandsApiKeyHelpProps { testId: string; @@ -69,6 +72,13 @@ function LlmSettingsScreen() { const { data: resources } = useAIConfigOptions(); const { data: settings, isLoading, isFetching } = useSettings(); const { data: config } = useConfig(); + const { data: me } = useMe(); + const { hasPermission } = usePermission(me?.role ?? "member"); + + // In OSS mode, user has full access (no permission restrictions) + // In SaaS mode, check role-based permissions (members can only view, owners and admins can edit) + const isOssMode = config?.app_mode === "oss"; + const isReadOnly = isOssMode ? false : !hasPermission("edit_llm_settings"); const [view, setView] = React.useState<"basic" | "advanced">("basic"); @@ -499,6 +509,7 @@ function LlmSettingsScreen() { defaultIsToggled={view === "advanced"} onToggle={handleToggleAdvancedSettings} isToggled={view === "advanced"} + isDisabled={isReadOnly} > {t(I18nKey.SETTINGS$ADVANCED)} @@ -516,6 +527,7 @@ function LlmSettingsScreen() { onChange={handleModelIsDirty} onDefaultValuesChanged={onDefaultValuesChanged} wrapperClassName="!flex-col !gap-6" + isDisabled={isReadOnly} /> {(settings.llm_model?.startsWith("openhands/") || currentSelectedModel?.startsWith("openhands/")) && ( @@ -534,6 +546,7 @@ function LlmSettingsScreen() { className="w-full max-w-[680px]" placeholder={settings.llm_api_key_set ? "" : ""} onChange={handleApiKeyIsDirty} + isDisabled={isReadOnly} startContent={ settings.llm_api_key_set && ( @@ -566,6 +579,7 @@ function LlmSettingsScreen() { type="text" className="w-full max-w-[680px]" onChange={handleCustomModelIsDirty} + isDisabled={isReadOnly} /> {(settings.llm_model?.startsWith("openhands/") || currentSelectedModel?.startsWith("openhands/")) && ( @@ -581,6 +595,7 @@ function LlmSettingsScreen() { type="text" className="w-full max-w-[680px]" onChange={handleBaseUrlIsDirty} + isDisabled={isReadOnly} /> {!shouldUseOpenHandsKey && ( @@ -593,6 +608,7 @@ function LlmSettingsScreen() { className="w-full max-w-[680px]" placeholder={settings.llm_api_key_set ? "" : ""} onChange={handleApiKeyIsDirty} + isDisabled={isReadOnly} startContent={ settings.llm_api_key_set && ( @@ -647,6 +663,7 @@ function LlmSettingsScreen() { defaultSelectedKey={settings.agent} isClearable={false} onInputChange={handleAgentIsDirty} + isDisabled={isReadOnly} wrapperClassName="w-full max-w-[680px]" /> )} @@ -666,7 +683,7 @@ function LlmSettingsScreen() { DEFAULT_SETTINGS.condenser_max_size )?.toString()} onChange={(value) => handleCondenserMaxSizeIsDirty(value)} - isDisabled={!settings.enable_default_condenser} + isDisabled={isReadOnly || !settings.enable_default_condenser} className="w-full max-w-[680px] capitalize" />

@@ -679,6 +696,7 @@ function LlmSettingsScreen() { name="enable-memory-condenser-switch" defaultIsToggled={settings.enable_default_condenser} onToggle={handleEnableDefaultCondenserIsDirty} + isDisabled={isReadOnly} > {t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)} @@ -691,6 +709,7 @@ function LlmSettingsScreen() { onToggle={handleConfirmationModeIsDirty} defaultIsToggled={settings.confirmation_mode} isBeta + isDisabled={isReadOnly} > {t(I18nKey.SETTINGS$CONFIRMATION_MODE)} @@ -716,6 +735,7 @@ function LlmSettingsScreen() { )} selectedKey={selectedSecurityAnalyzer || "none"} isClearable={false} + isDisabled={isReadOnly} onSelectionChange={(key) => { const newValue = key?.toString() || ""; setSelectedSecurityAnalyzer(newValue); @@ -746,20 +766,26 @@ function LlmSettingsScreen() { )}

-
- - {!isPending && t("SETTINGS$SAVE_CHANGES")} - {isPending && t("SETTINGS$SAVING")} - -
+ {!isReadOnly && ( +
+ + {!isPending && t("SETTINGS$SAVE_CHANGES")} + {isPending && t("SETTINGS$SAVING")} + +
+ )}
); } +// Route protection: all roles have view_llm_settings, but this guard ensures +// consistency with other routes and allows future restrictions if needed +export const clientLoader = createPermissionGuard("view_llm_settings"); + export default LlmSettingsScreen; diff --git a/frontend/src/routes/manage-org.tsx b/frontend/src/routes/manage-org.tsx new file mode 100644 index 0000000000..cff5429344 --- /dev/null +++ b/frontend/src/routes/manage-org.tsx @@ -0,0 +1,219 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session"; +import { useOrganization } from "#/hooks/query/use-organization"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalButtonGroup } from "#/components/shared/modals/modal-button-group"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { useMe } from "#/hooks/query/use-me"; +import { useConfig } from "#/hooks/query/use-config"; +import { I18nKey } from "#/i18n/declaration"; +import { amountIsValid } from "#/utils/amount-is-valid"; +import { CreditsChip } from "#/ui/credits-chip"; +import { InteractiveChip } from "#/ui/interactive-chip"; +import { usePermission } from "#/hooks/organizations/use-permissions"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; +import { isBillingHidden } from "#/utils/org/billing-visibility"; +import { DeleteOrgConfirmationModal } from "#/components/features/org/delete-org-confirmation-modal"; +import { ChangeOrgNameModal } from "#/components/features/org/change-org-name-modal"; +import { useBalance } from "#/hooks/query/use-balance"; +import { cn } from "#/utils/utils"; + +interface AddCreditsModalProps { + onClose: () => void; +} + +function AddCreditsModal({ onClose }: AddCreditsModalProps) { + const { t } = useTranslation(); + const { mutate: addBalance } = useCreateStripeCheckoutSession(); + + const [inputValue, setInputValue] = React.useState(""); + const [errorMessage, setErrorMessage] = React.useState(null); + + const getErrorMessage = (value: string): string | null => { + if (!value.trim()) return null; + + const numValue = parseInt(value, 10); + if (Number.isNaN(numValue)) { + return t(I18nKey.PAYMENT$ERROR_INVALID_NUMBER); + } + if (numValue < 0) { + return t(I18nKey.PAYMENT$ERROR_NEGATIVE_AMOUNT); + } + if (numValue < 10) { + return t(I18nKey.PAYMENT$ERROR_MINIMUM_AMOUNT); + } + if (numValue > 25000) { + return t(I18nKey.PAYMENT$ERROR_MAXIMUM_AMOUNT); + } + if (numValue !== parseFloat(value)) { + return t(I18nKey.PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER); + } + return null; + }; + + const formAction = (formData: FormData) => { + const amount = formData.get("amount")?.toString(); + + if (amount?.trim()) { + if (!amountIsValid(amount)) { + const error = getErrorMessage(amount); + setErrorMessage(error || "Invalid amount"); + return; + } + + const intValue = parseInt(amount, 10); + + addBalance({ amount: intValue }, { onSuccess: onClose }); + + setErrorMessage(null); + } + }; + + const handleAmountInputChange = (value: string) => { + setInputValue(value); + setErrorMessage(null); + }; + + return ( + +
+

{t(I18nKey.ORG$ADD_CREDITS)}

+
+ handleAmountInputChange(value)} + className="w-full" + /> + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ + + +
+ ); +} + +export const clientLoader = createPermissionGuard("view_billing"); + +function ManageOrg() { + const { t } = useTranslation(); + const { data: me } = useMe(); + const { data: organization } = useOrganization(); + const { data: balance } = useBalance(); + const { data: config } = useConfig(); + + const role = me?.role ?? "member"; + const { hasPermission } = usePermission(role); + + const [addCreditsFormVisible, setAddCreditsFormVisible] = + React.useState(false); + const [changeOrgNameFormVisible, setChangeOrgNameFormVisible] = + React.useState(false); + const [deleteOrgConfirmationVisible, setDeleteOrgConfirmationVisible] = + React.useState(false); + + const canChangeOrgName = !!me && hasPermission("change_organization_name"); + const canDeleteOrg = !!me && hasPermission("delete_organization"); + const canAddCredits = !!me && hasPermission("add_credits"); + const shouldHideBilling = isBillingHidden( + config, + hasPermission("view_billing"), + ); + + return ( +
+ {changeOrgNameFormVisible && ( + setChangeOrgNameFormVisible(false)} + /> + )} + {deleteOrgConfirmationVisible && ( + setDeleteOrgConfirmationVisible(false)} + /> + )} + + {!shouldHideBilling && ( +
+ + {t(I18nKey.ORG$CREDITS)} + +
+ + ${Number(balance ?? 0).toFixed(2)} + + {canAddCredits && ( + setAddCreditsFormVisible(true)}> + {t(I18nKey.ORG$ADD)} + + )} +
+
+ )} + + {addCreditsFormVisible && !shouldHideBilling && ( + setAddCreditsFormVisible(false)} /> + )} + +
+ + {t(I18nKey.ORG$ORGANIZATION_NAME)} + + +
+ {organization?.name} + {canChangeOrgName && ( + + )} +
+
+ + {canDeleteOrg && ( + + )} +
+ ); +} + +export default ManageOrg; diff --git a/frontend/src/routes/manage-organization-members.tsx b/frontend/src/routes/manage-organization-members.tsx new file mode 100644 index 0000000000..1561c289ba --- /dev/null +++ b/frontend/src/routes/manage-organization-members.tsx @@ -0,0 +1,269 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { useTranslation } from "react-i18next"; +import { LoaderCircle, Plus, Search } from "lucide-react"; +import { InviteOrganizationMemberModal } from "#/components/features/org/invite-organization-member-modal"; +import { ConfirmRemoveMemberModal } from "#/components/features/org/confirm-remove-member-modal"; +import { ConfirmUpdateRoleModal } from "#/components/features/org/confirm-update-role-modal"; +import { useOrganizationMembers } from "#/hooks/query/use-organization-members"; +import { useOrganizationMembersCount } from "#/hooks/query/use-organization-members-count"; +import { OrganizationMember, OrganizationUserRole } from "#/types/org"; +import { OrganizationMemberListItem } from "#/components/features/org/organization-member-list-item"; +import { useUpdateMemberRole } from "#/hooks/mutation/use-update-member-role"; +import { useRemoveMember } from "#/hooks/mutation/use-remove-member"; +import { useMe } from "#/hooks/query/use-me"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { rolePermissions } from "#/utils/org/permissions"; +import { I18nKey } from "#/i18n/declaration"; +import { usePermission } from "#/hooks/organizations/use-permissions"; +import { getAvailableRolesAUserCanAssign } from "#/utils/org/permission-checks"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; +import { Typography } from "#/ui/typography"; +import { Pagination } from "#/ui/pagination"; +import { useDebounce } from "#/hooks/use-debounce"; + +export const clientLoader = createPermissionGuard( + "invite_user_to_organization", +); + +export const handle = { hideTitle: true }; + +function ManageOrganizationMembers() { + const { t } = useTranslation(); + + // Pagination and filtering state + const [page, setPage] = React.useState(1); + const [emailFilter, setEmailFilter] = React.useState(""); + const debouncedEmailFilter = useDebounce(emailFilter, 300); + + // Reset to page 1 when filter changes + React.useEffect(() => { + setPage(1); + }, [debouncedEmailFilter]); + + const limit = 10; + + const { + data: membersData, + isLoading, + isFetching, + error: membersError, + } = useOrganizationMembers({ + page, + limit, + email: debouncedEmailFilter, + }); + + const { data: totalCount, error: countError } = useOrganizationMembersCount({ + email: debouncedEmailFilter, + }); + + const hasError = membersError || countError; + + const { data: user } = useMe(); + const { mutate: updateMemberRole, isPending: isUpdatingRole } = + useUpdateMemberRole(); + const { mutate: removeMember, isPending: isRemovingMember } = + useRemoveMember(); + const [inviteModalOpen, setInviteModalOpen] = React.useState(false); + const [memberToRemove, setMemberToRemove] = + React.useState(null); + const [memberToUpdateRole, setMemberToUpdateRole] = React.useState<{ + member: OrganizationMember; + newRole: OrganizationUserRole; + } | null>(null); + + const currentUserRole = user?.role ?? "member"; + + const { hasPermission } = usePermission(currentUserRole); + const hasPermissionToInvite = hasPermission("invite_user_to_organization"); + + // Calculate total pages + const totalPages = + totalCount !== undefined ? Math.ceil(totalCount / limit) : 0; + + const handleRoleSelectionClick = ( + member: OrganizationMember, + role: OrganizationUserRole, + ) => { + // Don't show modal if the role is the same + if (member.role === role) { + return; + } + setMemberToUpdateRole({ member, newRole: role }); + }; + + const handleConfirmUpdateRole = () => { + if (memberToUpdateRole) { + updateMemberRole( + { + userId: memberToUpdateRole.member.user_id, + role: memberToUpdateRole.newRole, + }, + { onSettled: () => setMemberToUpdateRole(null) }, + ); + } + }; + + const handleRemoveMember = (member: OrganizationMember) => { + setMemberToRemove(member); + }; + + const handleConfirmRemoveMember = () => { + if (memberToRemove) { + removeMember( + { userId: memberToRemove.user_id }, + { onSettled: () => setMemberToRemove(null) }, + ); + } + }; + + const availableRolesToChangeTo = getAvailableRolesAUserCanAssign( + rolePermissions[currentUserRole], + ); + + const canAssignUserRole = (member: OrganizationMember) => + user != null && + user?.user_id !== member.user_id && + hasPermission(`change_user_role:${member.role}`); + + return ( +
+
+ {t(I18nKey.ORG$ORGANIZATION_MEMBERS)} + {hasPermissionToInvite && ( + setInviteModalOpen(true)} + startContent={} + > + {t(I18nKey.ORG$INVITE_ORG_MEMBERS)} + + )} +
+ + {/* Email Search Input */} +
+ + setEmailFilter(e.target.value)} + className="w-full leading-4 font-normal bg-transparent placeholder:italic placeholder:text-tertiary-alt outline-none" + /> + {isFetching && debouncedEmailFilter && ( + + )} +
+ + {inviteModalOpen && + ReactDOM.createPortal( + setInviteModalOpen(false)} + />, + document.getElementById("portal-root") || document.body, + )} + +
+
+ {t(I18nKey.ORG$ALL_ORGANIZATION_MEMBERS)} + {totalCount !== undefined && ( + + {totalCount} {totalCount === 1 ? "member" : "members"} + + )} +
+ + {isLoading && ( +
+ Loading... +
+ )} + + {!isLoading && hasError && ( +
+ {t(I18nKey.ORG$FAILED_TO_LOAD_MEMBERS)} +
+ )} + + {!isLoading && + !hasError && + membersData?.items && + membersData.items.length > 0 && ( +
    + {membersData.items.map((member) => ( +
  • + + handleRoleSelectionClick(member, role) + } + onRemove={() => handleRemoveMember(member)} + /> +
  • + ))} +
+ )} + + {!isLoading && + !hasError && + (!membersData?.items || membersData.items.length === 0) && ( +
+ {debouncedEmailFilter + ? t(I18nKey.ORG$NO_MEMBERS_MATCHING_FILTER) + : t(I18nKey.ORG$NO_MEMBERS_FOUND)} +
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( + + )} + + {memberToRemove && ( + setMemberToRemove(null)} + isLoading={isRemovingMember} + /> + )} + + {memberToUpdateRole && ( + setMemberToUpdateRole(null)} + isLoading={isUpdatingRole} + /> + )} +
+ ); +} + +export default ManageOrganizationMembers; diff --git a/frontend/src/routes/mcp-settings.tsx b/frontend/src/routes/mcp-settings.tsx index e308b45228..536e0f1c28 100644 --- a/frontend/src/routes/mcp-settings.tsx +++ b/frontend/src/routes/mcp-settings.tsx @@ -11,6 +11,9 @@ import { MCPServerForm } from "#/components/features/settings/mcp-settings/mcp-s import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal"; import { BrandButton } from "#/components/features/settings/brand-button"; import { MCPConfig } from "#/types/settings"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; + +export const clientLoader = createPermissionGuard("manage_mcp"); type MCPServerType = "sse" | "stdio" | "shttp"; diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index 28cf7eee21..b61c281379 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -17,13 +17,13 @@ import { ReauthModal } from "#/components/features/waitlist/reauth-modal"; import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal"; import { useSettings } from "#/hooks/query/use-settings"; import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent"; -import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal"; import { displaySuccessToast } from "#/utils/custom-toast-handlers"; import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page"; import { useAutoLogin } from "#/hooks/use-auto-login"; import { useAuthCallback } from "#/hooks/use-auth-callback"; import { useReoTracking } from "#/hooks/use-reo-tracking"; import { useSyncPostHogConsent } from "#/hooks/use-sync-posthog-consent"; +import { useAutoSelectOrganization } from "#/hooks/use-auto-select-organization"; import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage"; import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard"; import { AlertBanner } from "#/components/features/alerts/alert-banner"; @@ -96,6 +96,9 @@ export default function MainApp() { // Sync PostHog opt-in/out state with backend setting on mount useSyncPostHogConsent(); + // Auto-select the first organization when none is selected + useAutoSelectOrganization(); + React.useEffect(() => { // Don't change language when on intermediate pages (TOS, profile questions) if (!isOnIntermediatePage && settings?.language) { @@ -259,10 +262,6 @@ export default function MainApp() { }} /> )} - - {config.data?.feature_flags.enable_billing && - config.data?.app_mode === "saas" && - settings?.is_new_user && }
); } diff --git a/frontend/src/routes/secrets-settings.tsx b/frontend/src/routes/secrets-settings.tsx index d6e81fbf97..ec6a9c3a28 100644 --- a/frontend/src/routes/secrets-settings.tsx +++ b/frontend/src/routes/secrets-settings.tsx @@ -12,6 +12,9 @@ import { BrandButton } from "#/components/features/settings/brand-button"; import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal"; import { GetSecretsResponse } from "#/api/secrets-service.types"; import { I18nKey } from "#/i18n/declaration"; +import { createPermissionGuard } from "#/utils/org/permission-guard"; + +export const clientLoader = createPermissionGuard("manage_secrets"); function SecretsSettingsScreen() { const queryClient = useQueryClient(); diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index b617e43549..cc1c3563c6 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -1,84 +1,38 @@ import { useMemo } from "react"; -import { Outlet, redirect, useLocation } from "react-router"; +import { Outlet, redirect, useLocation, useMatches } from "react-router"; import { useTranslation } from "react-i18next"; import { Route } from "./+types/settings"; import OptionService from "#/api/option-service/option-service.api"; import { queryClient } from "#/query-client-config"; -import { - WebClientConfig, - WebClientFeatureFlags, -} from "#/api/option-service/option.types"; -import { SettingsLayout } from "#/components/features/settings/settings-layout"; +import { SettingsLayout } from "#/components/features/settings"; +import { WebClientConfig } from "#/api/option-service/option.types"; +import { Organization } from "#/types/org"; import { Typography } from "#/ui/typography"; import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; +import { getActiveOrganizationUser } from "#/utils/org/permission-checks"; +import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store"; +import { rolePermissions } from "#/utils/org/permissions"; +import { isBillingHidden } from "#/utils/org/billing-visibility"; +import { + isSettingsPageHidden, + getFirstAvailablePath, +} from "#/utils/settings-utils"; const SAAS_ONLY_PATHS = [ "/settings/user", "/settings/billing", "/settings/credits", "/settings/api-keys", + "/settings/team", + "/settings/org", ]; -/** - * Checks if a settings page should be hidden based on feature flags. - * Used by both the route loader and navigation hook to keep logic in sync. - */ -export function isSettingsPageHidden( - path: string, - featureFlags: WebClientFeatureFlags | undefined, -): boolean { - if (featureFlags?.hide_llm_settings && path === "/settings") return true; - if (featureFlags?.hide_users_page && path === "/settings/user") return true; - if (featureFlags?.hide_billing_page && path === "/settings/billing") - return true; - if (featureFlags?.hide_integrations_page && path === "/settings/integrations") - return true; - return false; -} - -/** - * Find the first available settings page that is not hidden. - * Returns null if no page is available (shouldn't happen in practice). - */ -export function getFirstAvailablePath( - isSaas: boolean, - featureFlags: WebClientFeatureFlags | undefined, -): string | null { - const saasFallbackOrder = [ - { path: "/settings/user", hidden: !!featureFlags?.hide_users_page }, - { - path: "/settings/integrations", - hidden: !!featureFlags?.hide_integrations_page, - }, - { path: "/settings/app", hidden: false }, - { path: "/settings", hidden: !!featureFlags?.hide_llm_settings }, - { path: "/settings/billing", hidden: !!featureFlags?.hide_billing_page }, - { path: "/settings/secrets", hidden: false }, - { path: "/settings/api-keys", hidden: false }, - { path: "/settings/mcp", hidden: false }, - ]; - - const ossFallbackOrder = [ - { path: "/settings", hidden: !!featureFlags?.hide_llm_settings }, - { path: "/settings/mcp", hidden: false }, - { - path: "/settings/integrations", - hidden: !!featureFlags?.hide_integrations_page, - }, - { path: "/settings/app", hidden: false }, - { path: "/settings/secrets", hidden: false }, - ]; - - const fallbackOrder = isSaas ? saasFallbackOrder : ossFallbackOrder; - const firstAvailable = fallbackOrder.find((item) => !item.hidden); - - return firstAvailable?.path ?? null; -} - export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { const url = new URL(request.url); const { pathname } = url; + console.log("clientLoader", { pathname }); + // Step 1: Get config first (needed for all checks, no user data required) let config = queryClient.getQueryData(["web-client-config"]); if (!config) { config = await OptionService.getConfig(); @@ -88,17 +42,75 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { const isSaas = config?.app_mode === "saas"; const featureFlags = config?.feature_flags; - // Check if current page should be hidden and redirect to first available page - const isHiddenPage = - (!isSaas && SAAS_ONLY_PATHS.includes(pathname)) || - isSettingsPageHidden(pathname, featureFlags); + // Step 2: Check SAAS_ONLY_PATHS for OSS mode (no user data required) + if (!isSaas && SAAS_ONLY_PATHS.includes(pathname)) { + return redirect("/settings"); + } - if (isHiddenPage) { + // Step 3: Check feature flag-based hiding and redirect IMMEDIATELY (no user data required) + // This handles hide_llm_settings, hide_users_page, hide_billing_page, hide_integrations_page + if (isSettingsPageHidden(pathname, featureFlags)) { const fallbackPath = getFirstAvailablePath(isSaas, featureFlags); + console.log("fallbackPath", fallbackPath); if (fallbackPath && fallbackPath !== pathname) { return redirect(fallbackPath); } - // If no fallback available or same as current, stay on current page + } + + // Step 4: For routes that need permission checks, get user data + // Only fetch user data for billing and org routes that need permission validation + if ( + pathname === "/settings/billing" || + pathname === "/settings/org" || + pathname === "/settings/org-members" + ) { + const user = await getActiveOrganizationUser(); + + // Org-type detection for route protection + const orgId = getSelectedOrganizationIdFromStore(); + const organizationsData = queryClient.getQueryData<{ + items: Organization[]; + currentOrgId: string | null; + }>(["organizations"]); + const selectedOrg = organizationsData?.items?.find( + (org) => org.id === orgId, + ); + const isPersonalOrg = selectedOrg?.is_personal === true; + const isTeamOrg = !!selectedOrg && !selectedOrg.is_personal; + + // Billing route protection + if (pathname === "/settings/billing") { + if ( + !user || + isBillingHidden( + config, + rolePermissions[user.role ?? "member"].includes("view_billing"), + ) || + isTeamOrg + ) { + if (isSaas) { + const fallbackPath = getFirstAvailablePath(isSaas, featureFlags); + return redirect(fallbackPath ?? "/settings"); + } + } + } + + // Org route protection: redirect if user lacks required permissions or personal org + if (pathname === "/settings/org" || pathname === "/settings/org-members") { + const role = user?.role ?? "member"; + const requiredPermission = + pathname === "/settings/org" + ? "view_billing" + : "invite_user_to_organization"; + + if ( + !user || + !rolePermissions[role].includes(requiredPermission) || + isPersonalOrg + ) { + return redirect("/settings"); + } + } } return null; @@ -107,7 +119,9 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { function SettingsScreen() { const { t } = useTranslation(); const location = useLocation(); + const matches = useMatches(); const navItems = useSettingsNavItems(); + // Current section title for the main content area const currentSectionTitle = useMemo(() => { const currentItem = navItems.find((item) => item.to === location.pathname); @@ -117,11 +131,17 @@ function SettingsScreen() { : (navItems[0]?.text ?? "SETTINGS$TITLE"); }, [navItems, location.pathname]); + const routeHandle = matches.find((m) => m.pathname === location.pathname) + ?.handle as { hideTitle?: boolean } | undefined; + const shouldHideTitle = routeHandle?.hideTitle === true; + return (
- {t(currentSectionTitle)} + {!shouldHideTitle && ( + {t(currentSectionTitle)} + )}
diff --git a/frontend/src/stores/selected-organization-store.ts b/frontend/src/stores/selected-organization-store.ts new file mode 100644 index 0000000000..54f1a5a756 --- /dev/null +++ b/frontend/src/stores/selected-organization-store.ts @@ -0,0 +1,30 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +interface SelectedOrganizationState { + organizationId: string | null; +} + +interface SelectedOrganizationActions { + setOrganizationId: (orgId: string | null) => void; +} + +type SelectedOrganizationStore = SelectedOrganizationState & + SelectedOrganizationActions; + +const initialState: SelectedOrganizationState = { + organizationId: null, +}; + +export const useSelectedOrganizationStore = create()( + devtools( + (set) => ({ + ...initialState, + setOrganizationId: (organizationId) => set({ organizationId }), + }), + { name: "SelectedOrganizationStore" }, + ), +); + +export const getSelectedOrganizationIdFromStore = (): string | null => + useSelectedOrganizationStore.getState().organizationId; diff --git a/frontend/src/tailwind.css b/frontend/src/tailwind.css index e5edcece15..eee31d1d16 100644 --- a/frontend/src/tailwind.css +++ b/frontend/src/tailwind.css @@ -343,6 +343,14 @@ 0 0 0 1px rgba(0, 0, 0, 0.05); } +.modal-box-shadow { + box-shadow: 0 183px 51px 0 rgba(0, 0, 0, 0.00), 0 117px 47px 0 rgba(0, 0, 0, 0.01), 0 66px 40px 0 rgba(0, 0, 0, 0.03), 0 29px 29px 0 rgba(0, 0, 0, 0.04), 0 7px 16px 0 rgba(0, 0, 0, 0.05); +} + +.table-box-shadow { + box-shadow: 0 26px 7px 0 rgba(0, 0, 0, 0.00), 0 17px 7px 0 rgba(0, 0, 0, 0.01), 0 9px 6px 0 rgba(0, 0, 0, 0.03), 0 4px 4px 0 rgba(0, 0, 0, 0.04), 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + .git-external-link-icon { background: linear-gradient(90deg, rgba(69, 69, 69, 0) 0.35%, #454545 41.39%); } diff --git a/frontend/src/types/org.ts b/frontend/src/types/org.ts new file mode 100644 index 0000000000..a527d49c47 --- /dev/null +++ b/frontend/src/types/org.ts @@ -0,0 +1,58 @@ +export type OrganizationUserRole = "member" | "admin" | "owner"; + +export interface Organization { + id: string; + name: string; + contact_name: string; + contact_email: string; + conversation_expiration: number; + agent: string; + default_max_iterations: number; + security_analyzer: string; + confirmation_mode: boolean; + default_llm_model: string; + default_llm_api_key_for_byor: string; + default_llm_base_url: string; + remote_runtime_resource_factor: number; + enable_default_condenser: boolean; + billing_margin: number; + enable_proactive_conversation_starters: boolean; + sandbox_base_container_image: string; + sandbox_runtime_container_image: string; + org_version: number; + mcp_config: { + tools: unknown[]; + settings: Record; + }; + search_api_key: string | null; + sandbox_api_key: string | null; + max_budget_per_task: number; + enable_solvability_analysis: boolean; + v1_enabled: boolean; + credits: number; + is_personal?: boolean; +} + +export interface OrganizationMember { + org_id: string; + user_id: string; + email: string; + role: OrganizationUserRole; + llm_api_key: string; + max_iterations: number; + llm_model: string; + llm_api_key_for_byor: string | null; + llm_base_url: string; + status: "active" | "invited" | "inactive"; +} + +export interface OrganizationMembersPage { + items: OrganizationMember[]; + current_page: number; + per_page: number; +} + +/** org_id and user_id are provided via URL params */ +export type UpdateOrganizationMemberParams = Partial< + Omit +>; diff --git a/frontend/src/ui/context-menu-icon-text.tsx b/frontend/src/ui/context-menu-icon-text.tsx new file mode 100644 index 0000000000..9790ee03c2 --- /dev/null +++ b/frontend/src/ui/context-menu-icon-text.tsx @@ -0,0 +1,30 @@ +import { cn } from "#/utils/utils"; + +interface ContextMenuIconTextProps { + icon: React.ReactNode; + text: string; + rightIcon?: React.ReactNode; + className?: string; +} + +export function ContextMenuIconText({ + icon, + text, + rightIcon, + className, +}: ContextMenuIconTextProps) { + return ( +
+
+ {icon} + {text} +
+ {rightIcon &&
{rightIcon}
} +
+ ); +} diff --git a/frontend/src/ui/credits-chip.tsx b/frontend/src/ui/credits-chip.tsx new file mode 100644 index 0000000000..a27b6513b3 --- /dev/null +++ b/frontend/src/ui/credits-chip.tsx @@ -0,0 +1,30 @@ +import { cn } from "#/utils/utils"; + +interface CreditsChipProps { + testId?: string; + className?: string; +} + +/** + * Chip component for displaying credits amount + * Uses yellow background with black text for visibility + */ +export function CreditsChip({ + children, + testId, + className, +}: React.PropsWithChildren) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/ui/dropdown/clear-button.tsx b/frontend/src/ui/dropdown/clear-button.tsx new file mode 100644 index 0000000000..191e855a8d --- /dev/null +++ b/frontend/src/ui/dropdown/clear-button.tsx @@ -0,0 +1,19 @@ +import { X } from "lucide-react"; + +interface ClearButtonProps { + onClear: () => void; +} + +export function ClearButton({ onClear }: ClearButtonProps) { + return ( + + ); +} diff --git a/frontend/src/ui/dropdown/dropdown-input.tsx b/frontend/src/ui/dropdown/dropdown-input.tsx new file mode 100644 index 0000000000..26af0c3b94 --- /dev/null +++ b/frontend/src/ui/dropdown/dropdown-input.tsx @@ -0,0 +1,27 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { cn } from "#/utils/utils"; + +interface DropdownInputProps { + placeholder?: string; + isDisabled: boolean; + getInputProps: (props?: object) => object; +} + +export function DropdownInput({ + placeholder, + isDisabled, + getInputProps, +}: DropdownInputProps) { + return ( + + ); +} diff --git a/frontend/src/ui/dropdown/dropdown-menu.tsx b/frontend/src/ui/dropdown/dropdown-menu.tsx new file mode 100644 index 0000000000..7880089566 --- /dev/null +++ b/frontend/src/ui/dropdown/dropdown-menu.tsx @@ -0,0 +1,63 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { cn } from "#/utils/utils"; +import { DropdownOption } from "./types"; + +interface DropdownMenuProps { + isOpen: boolean; + filteredOptions: DropdownOption[]; + selectedItem: DropdownOption | null; + emptyMessage: string; + getMenuProps: (props?: object) => object; + getItemProps: (props: { + item: DropdownOption; + index: number; + className?: string; + }) => object; +} + +export function DropdownMenu({ + isOpen, + filteredOptions, + selectedItem, + emptyMessage, + getMenuProps, + getItemProps, +}: DropdownMenuProps) { + return ( +
+
    + {isOpen && filteredOptions.length === 0 && ( +
  • + {emptyMessage} +
  • + )} + {isOpen && + filteredOptions.map((option, index) => ( +
  • + {option.label} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/ui/dropdown/dropdown.tsx b/frontend/src/ui/dropdown/dropdown.tsx new file mode 100644 index 0000000000..229a821faa --- /dev/null +++ b/frontend/src/ui/dropdown/dropdown.tsx @@ -0,0 +1,128 @@ +import React, { useState } from "react"; +import { useCombobox } from "downshift"; +import { cn } from "#/utils/utils"; +import { DropdownOption } from "./types"; +import { LoadingSpinner } from "./loading-spinner"; +import { ClearButton } from "./clear-button"; +import { ToggleButton } from "./toggle-button"; +import { DropdownMenu } from "./dropdown-menu"; +import { DropdownInput } from "./dropdown-input"; + +interface DropdownProps { + options: DropdownOption[]; + emptyMessage?: string; + clearable?: boolean; + loading?: boolean; + disabled?: boolean; + placeholder?: string; + defaultValue?: DropdownOption; + onChange?: (item: DropdownOption | null) => void; + testId?: string; +} + +export function Dropdown({ + options, + emptyMessage = "No options", + clearable = false, + loading = false, + disabled = false, + placeholder, + defaultValue, + onChange, + testId, +}: DropdownProps) { + const [inputValue, setInputValue] = useState(defaultValue?.label ?? ""); + const [searchTerm, setSearchTerm] = useState(""); + + const filteredOptions = options.filter((option) => + option.label.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + const { + isOpen, + selectedItem, + selectItem, + getToggleButtonProps, + getMenuProps, + getItemProps, + getInputProps, + } = useCombobox({ + items: filteredOptions, + itemToString: (item) => item?.label ?? "", + inputValue, + stateReducer: (state, actionAndChanges) => + actionAndChanges.type === useCombobox.stateChangeTypes.InputClick && + state.isOpen + ? { ...actionAndChanges.changes, isOpen: true } + : actionAndChanges.changes, + onInputValueChange: ({ inputValue: newValue }) => { + setInputValue(newValue ?? ""); + setSearchTerm(newValue ?? ""); + }, + defaultSelectedItem: defaultValue, + onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { + onChange?.(newSelectedItem ?? null); + }, + onIsOpenChange: ({ + isOpen: newIsOpen, + selectedItem: currentSelectedItem, + }) => { + if (newIsOpen) { + setSearchTerm(""); + } else { + setInputValue(currentSelectedItem?.label ?? ""); + setSearchTerm(""); + } + }, + }); + + const isDisabled = loading || disabled; + + // Wrap getInputProps to inject a direct onChange handler that preserves + // cursor position. Downshift's default onInputValueChange resets cursor + // to end of input on every keystroke; reading from e.target.value keeps + // the browser's native cursor position intact. + const getInputPropsWithCursorFix = (props?: object) => + getInputProps({ + ...props, + onChange: (e: React.ChangeEvent) => { + setInputValue(e.target.value); + setSearchTerm(e.target.value); + }, + }); + + return ( +
+
+ + {loading && } + {clearable && selectedItem && ( + selectItem(null)} /> + )} + +
+ +
+ ); +} diff --git a/frontend/src/ui/dropdown/loading-spinner.tsx b/frontend/src/ui/dropdown/loading-spinner.tsx new file mode 100644 index 0000000000..18f5fe3aa1 --- /dev/null +++ b/frontend/src/ui/dropdown/loading-spinner.tsx @@ -0,0 +1,8 @@ +export function LoadingSpinner() { + return ( +
+ ); +} diff --git a/frontend/src/ui/dropdown/toggle-button.tsx b/frontend/src/ui/dropdown/toggle-button.tsx new file mode 100644 index 0000000000..59ab198510 --- /dev/null +++ b/frontend/src/ui/dropdown/toggle-button.tsx @@ -0,0 +1,31 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { ChevronDown } from "lucide-react"; +import { cn } from "#/utils/utils"; + +interface ToggleButtonProps { + isOpen: boolean; + isDisabled: boolean; + getToggleButtonProps: (props?: object) => object; +} + +export function ToggleButton({ + isOpen, + isDisabled, + getToggleButtonProps, +}: ToggleButtonProps) { + return ( + + ); +} diff --git a/frontend/src/ui/dropdown/types.ts b/frontend/src/ui/dropdown/types.ts new file mode 100644 index 0000000000..511e5d707d --- /dev/null +++ b/frontend/src/ui/dropdown/types.ts @@ -0,0 +1,4 @@ +export interface DropdownOption { + value: string; + label: string; +} diff --git a/frontend/src/ui/interactive-chip.tsx b/frontend/src/ui/interactive-chip.tsx new file mode 100644 index 0000000000..224e98d7d8 --- /dev/null +++ b/frontend/src/ui/interactive-chip.tsx @@ -0,0 +1,33 @@ +import { cn } from "#/utils/utils"; + +interface InteractiveChipProps { + onClick: () => void; + testId?: string; + className?: string; +} + +/** + * Small clickable chip component for actions like "Add" + * Uses gray background with black text + */ +export function InteractiveChip({ + children, + onClick, + testId, + className, +}: React.PropsWithChildren) { + return ( + + ); +} diff --git a/frontend/src/ui/pagination.tsx b/frontend/src/ui/pagination.tsx new file mode 100644 index 0000000000..4052f3ee7f --- /dev/null +++ b/frontend/src/ui/pagination.tsx @@ -0,0 +1,129 @@ +import { ChevronLeft } from "#/assets/chevron-left"; +import { ChevronRight } from "#/assets/chevron-right"; +import { cn } from "#/utils/utils"; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + className?: string; +} + +export function Pagination({ + currentPage, + totalPages, + onPageChange, + className, +}: PaginationProps) { + // Generate page numbers to display + const getPageNumbers = (): (number | "ellipsis")[] => { + const pages: (number | "ellipsis")[] = []; + const showEllipsis = totalPages > 7; + + if (!showEllipsis) { + // Show all pages if 7 or fewer + for (let i = 1; i <= totalPages; i += 1) { + pages.push(i); + } + } else { + // Always show first page + pages.push(1); + + if (currentPage > 3) { + pages.push("ellipsis"); + } + + // Show pages around current + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + + for (let i = start; i <= end; i += 1) { + pages.push(i); + } + + if (currentPage < totalPages - 2) { + pages.push("ellipsis"); + } + + // Always show last page + if (totalPages > 1) { + pages.push(totalPages); + } + } + + return pages; + }; + + const canGoPrevious = currentPage > 1; + const canGoNext = currentPage < totalPages; + + if (totalPages <= 1) { + return null; + } + + return ( + + ); +} diff --git a/frontend/src/utils/get-component-prop-types.ts b/frontend/src/utils/get-component-prop-types.ts new file mode 100644 index 0000000000..8309441283 --- /dev/null +++ b/frontend/src/utils/get-component-prop-types.ts @@ -0,0 +1,2 @@ +export type GetComponentPropTypes = + T extends React.ComponentType ? P : never; diff --git a/frontend/src/utils/input-validation.ts b/frontend/src/utils/input-validation.ts new file mode 100644 index 0000000000..1c9df98021 --- /dev/null +++ b/frontend/src/utils/input-validation.ts @@ -0,0 +1,35 @@ +// Email validation regex pattern +const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + +/** + * Validates if a string is a valid email address format + * @param email The email string to validate + * @returns true if the email format is valid, false otherwise + */ +export const isValidEmail = (email: string): boolean => EMAIL_REGEX.test(email); + +/** + * Validates an array of email addresses and returns the invalid ones + * @param emails Array of email strings to validate + * @returns Array of invalid email addresses + */ +export const getInvalidEmails = (emails: string[]): string[] => + emails.filter((email) => !isValidEmail(email)); + +/** + * Checks if all emails in an array are valid + * @param emails Array of email strings to validate + * @returns true if all emails are valid, false otherwise + */ +export const areAllEmailsValid = (emails: string[]): boolean => + emails.every((email) => isValidEmail(email)); + +/** + * Checks if an array contains duplicate values (case-insensitive for emails) + * @param values Array of strings to check + * @returns true if duplicates exist, false otherwise + */ +export const hasDuplicates = (values: string[]): boolean => { + const lowercased = values.map((v) => v.toLowerCase()); + return new Set(lowercased).size !== lowercased.length; +}; diff --git a/frontend/src/utils/org/billing-visibility.ts b/frontend/src/utils/org/billing-visibility.ts new file mode 100644 index 0000000000..8bbe422602 --- /dev/null +++ b/frontend/src/utils/org/billing-visibility.ts @@ -0,0 +1,19 @@ +import { WebClientConfig } from "#/api/option-service/option.types"; + +/** + * Determines whether billing should be hidden based on feature flags and user permissions. + * + * Returns true when billing UI should NOT be shown. This is the single source of truth + * for billing visibility decisions across loaders and hooks. + * + * @param config - The application config. When undefined (not yet loaded), billing is + * hidden as a safe default to prevent unauthorized access during loading. + * @param hasViewBillingPermission - Whether the current user has the view_billing permission. + */ +export function isBillingHidden( + config: WebClientConfig | undefined, + hasViewBillingPermission: boolean, +): boolean { + if (!config) return true; + return !config.feature_flags?.enable_billing || !hasViewBillingPermission; +} diff --git a/frontend/src/utils/org/permission-checks.ts b/frontend/src/utils/org/permission-checks.ts new file mode 100644 index 0000000000..5bf9db857e --- /dev/null +++ b/frontend/src/utils/org/permission-checks.ts @@ -0,0 +1,50 @@ +import { organizationService } from "#/api/organization-service/organization-service.api"; +import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store"; +import { OrganizationMember, OrganizationUserRole } from "#/types/org"; +import { PermissionKey } from "./permissions"; +import { queryClient } from "#/query-client-config"; + +/** + * Get the active organization user. + * Uses React Query's fetchQuery to leverage request deduplication, + * preventing duplicate API calls when multiple consumers request the same data. + * @returns OrganizationMember + */ +export const getActiveOrganizationUser = async (): Promise< + OrganizationMember | undefined +> => { + const orgId = getSelectedOrganizationIdFromStore(); + if (!orgId) return undefined; + + try { + const user = await queryClient.fetchQuery({ + queryKey: ["organizations", orgId, "me"], + queryFn: () => organizationService.getMe({ orgId }), + staleTime: 1000 * 60 * 5, // 5 minutes - matches useMe hook + }); + return user; + } catch { + return undefined; + } +}; + +/** + * Get a list of roles that a user has permission to assign to other users + * @param userPermissions all permission for active user + * @returns an array of roles (strings) the user can change other users to + */ +export const getAvailableRolesAUserCanAssign = ( + userPermissions: PermissionKey[], +): OrganizationUserRole[] => { + const availableRoles: OrganizationUserRole[] = []; + if (userPermissions.includes("change_user_role:member")) { + availableRoles.push("member"); + } + if (userPermissions.includes("change_user_role:admin")) { + availableRoles.push("admin"); + } + if (userPermissions.includes("change_user_role:owner")) { + availableRoles.push("owner"); + } + return availableRoles; +}; diff --git a/frontend/src/utils/org/permission-guard.ts b/frontend/src/utils/org/permission-guard.ts new file mode 100644 index 0000000000..fe8e5c67fe --- /dev/null +++ b/frontend/src/utils/org/permission-guard.ts @@ -0,0 +1,84 @@ +import { redirect } from "react-router"; +import OptionService from "#/api/option-service/option-service.api"; +import { WebClientConfig } from "#/api/option-service/option.types"; +import { queryClient } from "#/query-client-config"; +import { getFirstAvailablePath } from "#/utils/settings-utils"; +import { getActiveOrganizationUser } from "./permission-checks"; +import { PermissionKey, rolePermissions } from "./permissions"; + +/** + * Helper to get config, using cache or fetching if needed. + */ +async function getConfig(): Promise { + let config = queryClient.getQueryData(["web-client-config"]); + if (!config) { + config = await OptionService.getConfig(); + queryClient.setQueryData(["web-client-config"], config); + } + return config; +} + +/** + * Gets the appropriate fallback path for permission denied scenarios. + * Respects feature flags to avoid redirecting to hidden pages. + */ +async function getPermissionDeniedFallback(): Promise { + const config = await getConfig(); + + const isSaas = config?.app_mode === "saas"; + const featureFlags = config?.feature_flags; + + // Get first available path that respects feature flags + const fallbackPath = getFirstAvailablePath(isSaas, featureFlags); + return fallbackPath ?? "/settings"; +} + +/** + * Creates a clientLoader guard that checks if the user has the required permission. + * Redirects to the first available settings page if permission is denied. + * + * In OSS mode, permission checks are bypassed since there are no user roles. + * + * @param requiredPermission - The permission key to check + * @param customRedirectPath - Optional custom path to redirect to (will still respect feature flags if not provided) + * @returns A clientLoader function that can be exported from route files + */ +export const createPermissionGuard = + (requiredPermission: PermissionKey, customRedirectPath?: string) => + async ({ request }: { request: Request }) => { + // Get config to check app_mode + const config = await getConfig(); + + // In OSS mode, skip permission checks - all settings are accessible + if (config?.app_mode === "oss") { + return null; + } + + const user = await getActiveOrganizationUser(); + + const url = new URL(request.url); + const currentPath = url.pathname; + + // Helper to get redirect response, avoiding infinite loops + const getRedirectResponse = async () => { + const redirectPath = + customRedirectPath ?? (await getPermissionDeniedFallback()); + // Don't redirect to the same path to avoid infinite loops + if (redirectPath === currentPath) { + return null; + } + return redirect(redirectPath); + }; + + if (!user) { + return getRedirectResponse(); + } + + const userRole = user.role ?? "member"; + + if (!rolePermissions[userRole].includes(requiredPermission)) { + return getRedirectResponse(); + } + + return null; + }; diff --git a/frontend/src/utils/org/permissions.ts b/frontend/src/utils/org/permissions.ts new file mode 100644 index 0000000000..ab17f5b5e4 --- /dev/null +++ b/frontend/src/utils/org/permissions.ts @@ -0,0 +1,69 @@ +import { OrganizationUserRole } from "#/types/org"; + +/* PERMISSION TYPES */ +type UserRoleChangePermissionKey = `change_user_role:${OrganizationUserRole}`; +type InviteUserToOrganizationKey = "invite_user_to_organization"; + +type ChangeOrganizationNamePermission = "change_organization_name"; +type DeleteOrganizationPermission = "delete_organization"; +type AddCreditsPermission = "add_credits"; +type ViewBillingPermission = "view_billing"; + +type ManageSecretsPermission = "manage_secrets"; +type ManageMCPPermission = "manage_mcp"; +type ManageIntegrationsPermission = "manage_integrations"; +type ManageApplicationSettingsPermission = "manage_application_settings"; +type ManageAPIKeysPermission = "manage_api_keys"; + +type ViewLLMSettingsPermission = "view_llm_settings"; +type EditLLMSettingsPermission = "edit_llm_settings"; + +// Union of all permission keys +export type PermissionKey = + | UserRoleChangePermissionKey + | InviteUserToOrganizationKey + | ChangeOrganizationNamePermission + | DeleteOrganizationPermission + | AddCreditsPermission + | ViewBillingPermission + | ManageSecretsPermission + | ManageMCPPermission + | ManageIntegrationsPermission + | ManageApplicationSettingsPermission + | ManageAPIKeysPermission + | ViewLLMSettingsPermission + | EditLLMSettingsPermission; + +/* PERMISSION ARRAYS */ +const memberPerms: PermissionKey[] = [ + "manage_secrets", + "manage_mcp", + "manage_integrations", + "manage_application_settings", + "manage_api_keys", + "view_llm_settings", +]; + +const adminOnly: PermissionKey[] = [ + "edit_llm_settings", + "view_billing", + "add_credits", + "invite_user_to_organization", + "change_user_role:member", + "change_user_role:admin", +]; + +const ownerOnly: PermissionKey[] = [ + "change_organization_name", + "delete_organization", + "change_user_role:owner", +]; + +const adminPerms: PermissionKey[] = [...memberPerms, ...adminOnly]; +const ownerPerms: PermissionKey[] = [...adminPerms, ...ownerOnly]; + +export const rolePermissions: Record = { + owner: ownerPerms, + admin: adminPerms, + member: memberPerms, +}; diff --git a/frontend/src/utils/query-client-getters.ts b/frontend/src/utils/query-client-getters.ts new file mode 100644 index 0000000000..4b9fbb800c --- /dev/null +++ b/frontend/src/utils/query-client-getters.ts @@ -0,0 +1,5 @@ +import { queryClient } from "#/query-client-config"; +import { OrganizationMember } from "#/types/org"; + +export const getMeFromQueryClient = (orgId: string | null) => + queryClient.getQueryData(["organizations", orgId, "me"]); diff --git a/frontend/src/utils/settings-utils.ts b/frontend/src/utils/settings-utils.ts index 4259226d77..caa03e9aa7 100644 --- a/frontend/src/utils/settings-utils.ts +++ b/frontend/src/utils/settings-utils.ts @@ -1,5 +1,6 @@ import { Settings } from "#/types/settings"; import { getProviderId } from "#/utils/map-provider"; +import { WebClientFeatureFlags } from "#/api/option-service/option.types"; const extractBasicFormData = (formData: FormData) => { const providerDisplay = formData.get("llm-provider-input")?.toString(); @@ -91,3 +92,59 @@ export const extractSettings = (formData: FormData): Partial => { llm_api_key: LLM_API_KEY, }; }; + +/** + * Checks if a settings page should be hidden based on feature flags. + * Used by both the route loader and navigation hook to keep logic in sync. + */ +export function isSettingsPageHidden( + path: string, + featureFlags: WebClientFeatureFlags | undefined, +): boolean { + if (featureFlags?.hide_llm_settings && path === "/settings") return true; + if (featureFlags?.hide_users_page && path === "/settings/user") return true; + if (featureFlags?.hide_billing_page && path === "/settings/billing") + return true; + if (featureFlags?.hide_integrations_page && path === "/settings/integrations") + return true; + return false; +} + +/** + * Find the first available settings page that is not hidden. + * Returns null if no page is available (shouldn't happen in practice). + */ +export function getFirstAvailablePath( + isSaas: boolean, + featureFlags: WebClientFeatureFlags | undefined, +): string | null { + const saasFallbackOrder = [ + { path: "/settings/user", hidden: !!featureFlags?.hide_users_page }, + { + path: "/settings/integrations", + hidden: !!featureFlags?.hide_integrations_page, + }, + { path: "/settings/app", hidden: false }, + { path: "/settings", hidden: !!featureFlags?.hide_llm_settings }, + { path: "/settings/billing", hidden: !!featureFlags?.hide_billing_page }, + { path: "/settings/secrets", hidden: false }, + { path: "/settings/api-keys", hidden: false }, + { path: "/settings/mcp", hidden: false }, + ]; + + const ossFallbackOrder = [ + { path: "/settings", hidden: !!featureFlags?.hide_llm_settings }, + { path: "/settings/mcp", hidden: false }, + { + path: "/settings/integrations", + hidden: !!featureFlags?.hide_integrations_page, + }, + { path: "/settings/app", hidden: false }, + { path: "/settings/secrets", hidden: false }, + ]; + + const fallbackOrder = isSaas ? saasFallbackOrder : ossFallbackOrder; + const firstAvailable = fallbackOrder.find((item) => !item.hidden); + + return firstAvailable?.path ?? null; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 5fd1ea2403..ce7b82c92a 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -3,5 +3,25 @@ import { heroui } from "@heroui/react"; import typography from "@tailwindcss/typography"; export default { darkMode: "class", + theme: { + extend: { + colors: { + modal: { + background: "#171717", + input: "#27272A", + primary: "#F3CE49", + secondary: "#737373", + muted: "#A3A3A3", + }, + org: { + border: "#171717", + background: "#262626", + divider: "#525252", + button: "#737373", + text: "#A3A3A3", + }, + }, + }, + }, plugins: [typography], }; diff --git a/frontend/test-utils.tsx b/frontend/test-utils.tsx index fee6d71093..d47a86a372 100644 --- a/frontend/test-utils.tsx +++ b/frontend/test-utils.tsx @@ -1,12 +1,18 @@ -// Test utilities for React components - import React, { PropsWithChildren } from "react"; -import { RenderOptions, render } from "@testing-library/react"; +import { + act, + RenderOptions, + render, + screen, + waitFor, +} from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nextProvider, initReactI18next } from "react-i18next"; import i18n from "i18next"; -import { vi } from "vitest"; +import { expect, vi } from "vitest"; import { AxiosError } from "axios"; +import { INITIAL_MOCK_ORGS } from "#/mocks/org-handlers"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; import { ActionEvent, MessageEvent, @@ -26,6 +32,9 @@ vi.mock("react-router", async () => { return { ...actual, useParams: useParamsMock, + useRevalidator: () => ({ + revalidate: vi.fn(), + }), }; }); @@ -37,7 +46,10 @@ i18n.use(initReactI18next).init({ defaultNS: "translation", resources: { en: { - translation: {}, + translation: { + "ORG$PERSONAL_WORKSPACE": "Personal Workspace", + "ORG$SELECT_ORGANIZATION_PLACEHOLDER": "Please select an organization", + }, }, }, interpolation: { @@ -86,6 +98,47 @@ export const createAxiosNotFoundErrorObject = () => }, ); +export const selectOrganization = async ({ + orgIndex, +}: { + orgIndex: number; +}) => { + const targetOrg = INITIAL_MOCK_ORGS[orgIndex]; + if (!targetOrg) { + expect.fail(`No organization found at index ${orgIndex}`); + } + + // Wait for the settings navbar to render (which contains the org selector) + await screen.findByTestId("settings-navbar"); + + // Wait for orgs to load and org selector to be present + const organizationSelect = await screen.findByTestId("org-selector"); + expect(organizationSelect).toBeInTheDocument(); + + // Wait until the dropdown trigger is not disabled (orgs have loaded) + const trigger = await screen.findByTestId("dropdown-trigger"); + await waitFor(() => { + expect(trigger).not.toBeDisabled(); + }); + + // Set the organization ID directly in the Zustand store + // This is more reliable than UI interaction in router stub tests + // Use act() to ensure React processes the state update + act(() => { + useSelectedOrganizationStore.setState({ organizationId: targetOrg.id }); + }); + + // Get the combobox input and wait for it to reflect the selection + // For personal orgs, the display name is "Personal Workspace" (from i18n) + const expectedDisplayName = targetOrg.is_personal + ? "Personal Workspace" + : targetOrg.name; + const combobox = screen.getByRole("combobox"); + await waitFor(() => { + expect(combobox).toHaveValue(expectedDisplayName); + }); +}; + export const createAxiosError = ( status: number, statusText: string, diff --git a/frontend/tests/avatar-menu.spec.ts b/frontend/tests/avatar-menu.spec.ts index c7d49ac302..a8243dca9a 100644 --- a/frontend/tests/avatar-menu.spec.ts +++ b/frontend/tests/avatar-menu.spec.ts @@ -13,6 +13,39 @@ import test, { expect } from "@playwright/test"; test("avatar context menu stays open when moving cursor diagonally to menu", async ({ page, }) => { + // Intercept GET /api/settings to return settings with a configured provider. + // In OSS mode, the user context menu only renders when providers are configured. + await page.route("**/api/settings", async (route) => { + if (route.request().method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + llm_model: "openhands/claude-opus-4-5-20251101", + llm_base_url: "", + agent: "CodeActAgent", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + search_api_key_set: false, + confirmation_mode: false, + security_analyzer: "llm", + remote_runtime_resource_factor: 1, + provider_tokens_set: { github: "" }, + enable_default_condenser: true, + condenser_max_size: 240, + enable_sound_notifications: false, + user_consents_to_analytics: false, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + max_budget_per_task: null, + }), + }); + } else { + await route.continue(); + } + }); + await page.goto("/"); // Wait for the page to be fully loaded and check for AI config modal @@ -36,7 +69,8 @@ test("avatar context menu stays open when moving cursor diagonally to menu", asy // intercept clicks when the mouse triggers group-hover state await userAvatar.click({ force: true }); - const contextMenu = page.getByTestId("account-settings-context-menu"); + // The context menu should appear via CSS group-hover + const contextMenu = page.getByTestId("user-context-menu"); await expect(contextMenu).toBeVisible(); const menuWrapper = contextMenu.locator(".."); diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index aadbdd10b1..c43fa03553 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -10,7 +10,9 @@ window.scrollTo = vi.fn(); // Mock ResizeObserver for test environment class MockResizeObserver { observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); } From b4f00379b822723132961c822d3b3701d33e5be5 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:47:03 +0700 Subject: [PATCH 16/92] fix(frontend): auto-scroll not working in Planner tab when plan content updates (#13355) --- .../__tests__/routes/planner-tab.test.tsx | 82 ++++++++++++++++++- frontend/src/routes/planner-tab.tsx | 17 +++- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/frontend/__tests__/routes/planner-tab.test.tsx b/frontend/__tests__/routes/planner-tab.test.tsx index 8f139ffc5f..011f8649ed 100644 --- a/frontend/__tests__/routes/planner-tab.test.tsx +++ b/frontend/__tests__/routes/planner-tab.test.tsx @@ -1,5 +1,5 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { screen, act } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import PlannerTab from "#/routes/planner-tab"; import { renderWithProviders } from "../../test-utils"; import { useConversationStore } from "#/stores/conversation-store"; @@ -12,8 +12,15 @@ vi.mock("#/hooks/use-handle-plan-click", () => ({ })); describe("PlannerTab", () => { + const originalRAF = global.requestAnimationFrame; + beforeEach(() => { vi.clearAllMocks(); + // Make requestAnimationFrame execute synchronously for testing + global.requestAnimationFrame = (cb: FrameRequestCallback) => { + cb(0); + return 0; + }; // Reset store state to defaults useConversationStore.setState({ planContent: null, @@ -21,6 +28,10 @@ describe("PlannerTab", () => { }); }); + afterEach(() => { + global.requestAnimationFrame = originalRAF; + }); + describe("Create a plan button", () => { it("should be enabled when conversation mode is 'code'", () => { // Arrange @@ -52,4 +63,71 @@ describe("PlannerTab", () => { expect(button).toBeDisabled(); }); }); + + describe("Auto-scroll behavior", () => { + it("should scroll to bottom when plan content is updated", () => { + // Arrange + const scrollTopSetter = vi.fn(); + const mockScrollHeight = 500; + + // Mock scroll properties on HTMLElement prototype + const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollHeight", + ); + const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollTop", + ); + + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + get: () => mockScrollHeight, + configurable: true, + }); + Object.defineProperty(HTMLElement.prototype, "scrollTop", { + get: () => 0, + set: scrollTopSetter, + configurable: true, + }); + + try { + // Render with initial plan content + useConversationStore.setState({ + planContent: "# Initial Plan", + conversationMode: "plan", + }); + + renderWithProviders(); + + // Clear calls from initial render + scrollTopSetter.mockClear(); + + // Act - Update plan content which should trigger auto-scroll + act(() => { + useConversationStore.setState({ + planContent: "# Updated Plan\n\nMore content added here.", + }); + }); + + // Assert - scrollTop should be set to scrollHeight + expect(scrollTopSetter).toHaveBeenCalledWith(mockScrollHeight); + } finally { + // Restore original descriptors + if (originalScrollHeightDescriptor) { + Object.defineProperty( + HTMLElement.prototype, + "scrollHeight", + originalScrollHeightDescriptor, + ); + } + if (originalScrollTopDescriptor) { + Object.defineProperty( + HTMLElement.prototype, + "scrollTop", + originalScrollTopDescriptor, + ); + } + } + }); + }); }); diff --git a/frontend/src/routes/planner-tab.tsx b/frontend/src/routes/planner-tab.tsx index 11e5f8e3c0..3355971136 100644 --- a/frontend/src/routes/planner-tab.tsx +++ b/frontend/src/routes/planner-tab.tsx @@ -11,11 +11,22 @@ import { cn } from "#/utils/utils"; function PlannerTab() { const { t } = useTranslation(); - const { scrollRef: scrollContainerRef, onChatBodyScroll } = useScrollToBottom( - React.useRef(null), - ); + const scrollRef = React.useRef(null); + const { + scrollRef: scrollContainerRef, + onChatBodyScroll, + autoScroll, + scrollDomToBottom, + } = useScrollToBottom(scrollRef); const { planContent, conversationMode } = useConversationStore(); + + // Auto-scroll to bottom when plan content changes + React.useEffect(() => { + if (autoScroll) { + scrollDomToBottom(); + } + }, [planContent, autoScroll, scrollDomToBottom]); const isPlanMode = conversationMode === "plan"; const { handlePlanClick } = useHandlePlanClick(); From 0527c46bba98754e32dbff8d2c75eaf4829fe9d1 Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Fri, 13 Mar 2026 11:24:58 -0600 Subject: [PATCH 17/92] Add sandbox_id__eq filter to AppConversationService search and count methods (#13387) Co-authored-by: openhands --- .../app_conversation_router.py | 10 + .../app_conversation_service.py | 2 + .../live_status_app_conversation_service.py | 4 + .../test_app_conversation_router.py | 171 +++++++++++++++++- ...st_live_status_app_conversation_service.py | 114 ++++++++++++ 5 files changed, 299 insertions(+), 2 deletions(-) diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py index 54fc7c16b3..50a8497a85 100644 --- a/openhands/app_server/app_conversation/app_conversation_router.py +++ b/openhands/app_server/app_conversation/app_conversation_router.py @@ -117,6 +117,10 @@ async def search_app_conversations( datetime | None, Query(title='Filter by updated_at less than this datetime'), ] = None, + sandbox_id__eq: Annotated[ + str | None, + Query(title='Filter by exact sandbox_id'), + ] = None, page_id: Annotated[ str | None, Query(title='Optional next_page_id from the previously returned page'), @@ -148,6 +152,7 @@ async def search_app_conversations( created_at__lt=created_at__lt, updated_at__gte=updated_at__gte, updated_at__lt=updated_at__lt, + sandbox_id__eq=sandbox_id__eq, page_id=page_id, limit=limit, include_sub_conversations=include_sub_conversations, @@ -176,6 +181,10 @@ async def count_app_conversations( datetime | None, Query(title='Filter by updated_at less than this datetime'), ] = None, + sandbox_id__eq: Annotated[ + str | None, + Query(title='Filter by exact sandbox_id'), + ] = None, app_conversation_service: AppConversationService = ( app_conversation_service_dependency ), @@ -187,6 +196,7 @@ async def count_app_conversations( created_at__lt=created_at__lt, updated_at__gte=updated_at__gte, updated_at__lt=updated_at__lt, + sandbox_id__eq=sandbox_id__eq, ) diff --git a/openhands/app_server/app_conversation/app_conversation_service.py b/openhands/app_server/app_conversation/app_conversation_service.py index 97918c3267..1f955cac9c 100644 --- a/openhands/app_server/app_conversation/app_conversation_service.py +++ b/openhands/app_server/app_conversation/app_conversation_service.py @@ -29,6 +29,7 @@ class AppConversationService(ABC): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC, page_id: str | None = None, limit: int = 100, @@ -44,6 +45,7 @@ class AppConversationService(ABC): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, ) -> int: """Count sandboxed conversations.""" diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 97b92c6aa5..94b5740329 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -142,6 +142,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC, page_id: str | None = None, limit: int = 20, @@ -154,6 +155,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): created_at__lt=created_at__lt, updated_at__gte=updated_at__gte, updated_at__lt=updated_at__lt, + sandbox_id__eq=sandbox_id__eq, sort_order=sort_order, page_id=page_id, limit=limit, @@ -171,6 +173,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): created_at__lt: datetime | None = None, updated_at__gte: datetime | None = None, updated_at__lt: datetime | None = None, + sandbox_id__eq: str | None = None, ) -> int: return await self.app_conversation_info_service.count_app_conversation_info( title__contains=title__contains, @@ -178,6 +181,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): created_at__lt=created_at__lt, updated_at__gte=updated_at__gte, updated_at__lt=updated_at__lt, + sandbox_id__eq=sandbox_id__eq, ) async def get_app_conversation( diff --git a/tests/unit/app_server/test_app_conversation_router.py b/tests/unit/app_server/test_app_conversation_router.py index d052537d96..c655d4b422 100644 --- a/tests/unit/app_server/test_app_conversation_router.py +++ b/tests/unit/app_server/test_app_conversation_router.py @@ -12,21 +12,28 @@ from fastapi import HTTPException, status from openhands.app_server.app_conversation.app_conversation_models import ( AppConversation, + AppConversationPage, ) from openhands.app_server.app_conversation.app_conversation_router import ( batch_get_app_conversations, + count_app_conversations, + search_app_conversations, ) from openhands.app_server.sandbox.sandbox_models import SandboxStatus -def _make_mock_app_conversation(conversation_id=None, user_id='test-user'): +def _make_mock_app_conversation( + conversation_id=None, user_id='test-user', sandbox_id=None +): """Create a mock AppConversation for testing.""" if conversation_id is None: conversation_id = uuid4() + if sandbox_id is None: + sandbox_id = str(uuid4()) return AppConversation( id=conversation_id, created_by_user_id=user_id, - sandbox_id=str(uuid4()), + sandbox_id=sandbox_id, sandbox_status=SandboxStatus.RUNNING, ) @@ -34,11 +41,17 @@ def _make_mock_app_conversation(conversation_id=None, user_id='test-user'): def _make_mock_service( get_conversation_return=None, batch_get_return=None, + search_return=None, + count_return=0, ): """Create a mock AppConversationService for testing.""" service = MagicMock() service.get_app_conversation = AsyncMock(return_value=get_conversation_return) service.batch_get_app_conversations = AsyncMock(return_value=batch_get_return or []) + service.search_app_conversations = AsyncMock( + return_value=search_return or AppConversationPage(items=[]) + ) + service.count_app_conversations = AsyncMock(return_value=count_return) return service @@ -207,3 +220,157 @@ class TestBatchGetAppConversations: assert result[0] is not None assert result[0].id == uuid1 assert result[1] is None + + +@pytest.mark.asyncio +class TestSearchAppConversations: + """Test suite for search_app_conversations endpoint.""" + + async def test_search_with_sandbox_id_filter(self): + """Test that sandbox_id__eq filter is passed to the service. + + Arrange: Create mock service and specific sandbox_id + Act: Call search_app_conversations with sandbox_id__eq + Assert: Service is called with the sandbox_id__eq parameter + """ + # Arrange + sandbox_id = 'test-sandbox-123' + mock_conversation = _make_mock_app_conversation(sandbox_id=sandbox_id) + mock_service = _make_mock_service( + search_return=AppConversationPage(items=[mock_conversation]) + ) + + # Act + result = await search_app_conversations( + sandbox_id__eq=sandbox_id, + app_conversation_service=mock_service, + ) + + # Assert + mock_service.search_app_conversations.assert_called_once() + call_kwargs = mock_service.search_app_conversations.call_args[1] + assert call_kwargs.get('sandbox_id__eq') == sandbox_id + assert len(result.items) == 1 + assert result.items[0].sandbox_id == sandbox_id + + async def test_search_without_sandbox_id_filter(self): + """Test that sandbox_id__eq defaults to None when not provided. + + Arrange: Create mock service + Act: Call search_app_conversations without sandbox_id__eq + Assert: Service is called with sandbox_id__eq=None + """ + # Arrange + mock_service = _make_mock_service() + + # Act + await search_app_conversations( + app_conversation_service=mock_service, + ) + + # Assert + mock_service.search_app_conversations.assert_called_once() + call_kwargs = mock_service.search_app_conversations.call_args[1] + assert call_kwargs.get('sandbox_id__eq') is None + + async def test_search_with_sandbox_id_and_other_filters(self): + """Test that sandbox_id__eq works correctly with other filters. + + Arrange: Create mock service + Act: Call search_app_conversations with sandbox_id__eq and other filters + Assert: Service is called with all parameters correctly + """ + # Arrange + sandbox_id = 'test-sandbox-456' + mock_service = _make_mock_service() + + # Act + await search_app_conversations( + title__contains='test', + sandbox_id__eq=sandbox_id, + limit=50, + include_sub_conversations=True, + app_conversation_service=mock_service, + ) + + # Assert + mock_service.search_app_conversations.assert_called_once() + call_kwargs = mock_service.search_app_conversations.call_args[1] + assert call_kwargs.get('sandbox_id__eq') == sandbox_id + assert call_kwargs.get('title__contains') == 'test' + assert call_kwargs.get('limit') == 50 + assert call_kwargs.get('include_sub_conversations') is True + + +@pytest.mark.asyncio +class TestCountAppConversations: + """Test suite for count_app_conversations endpoint.""" + + async def test_count_with_sandbox_id_filter(self): + """Test that sandbox_id__eq filter is passed to the service. + + Arrange: Create mock service with count return value + Act: Call count_app_conversations with sandbox_id__eq + Assert: Service is called with the sandbox_id__eq parameter + """ + # Arrange + sandbox_id = 'test-sandbox-789' + mock_service = _make_mock_service(count_return=5) + + # Act + result = await count_app_conversations( + sandbox_id__eq=sandbox_id, + app_conversation_service=mock_service, + ) + + # Assert + mock_service.count_app_conversations.assert_called_once() + call_kwargs = mock_service.count_app_conversations.call_args[1] + assert call_kwargs.get('sandbox_id__eq') == sandbox_id + assert result == 5 + + async def test_count_without_sandbox_id_filter(self): + """Test that sandbox_id__eq defaults to None when not provided. + + Arrange: Create mock service + Act: Call count_app_conversations without sandbox_id__eq + Assert: Service is called with sandbox_id__eq=None + """ + # Arrange + mock_service = _make_mock_service(count_return=10) + + # Act + result = await count_app_conversations( + app_conversation_service=mock_service, + ) + + # Assert + mock_service.count_app_conversations.assert_called_once() + call_kwargs = mock_service.count_app_conversations.call_args[1] + assert call_kwargs.get('sandbox_id__eq') is None + assert result == 10 + + async def test_count_with_sandbox_id_and_other_filters(self): + """Test that sandbox_id__eq works correctly with other filters. + + Arrange: Create mock service + Act: Call count_app_conversations with sandbox_id__eq and other filters + Assert: Service is called with all parameters correctly + """ + # Arrange + sandbox_id = 'test-sandbox-abc' + mock_service = _make_mock_service(count_return=3) + + # Act + result = await count_app_conversations( + title__contains='test', + sandbox_id__eq=sandbox_id, + app_conversation_service=mock_service, + ) + + # Assert + mock_service.count_app_conversations.assert_called_once() + call_kwargs = mock_service.count_app_conversations.call_args[1] + assert call_kwargs.get('sandbox_id__eq') == sandbox_id + assert call_kwargs.get('title__contains') == 'test' + assert result == 3 diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index d9f79f79e2..ad9b4edb46 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -2097,6 +2097,120 @@ class TestLiveStatusAppConversationService: assert captured['workspace_working_dir'] == '/workspace/project' + @pytest.mark.asyncio + async def test_search_app_conversations_with_sandbox_id_filter(self): + """Test that search_app_conversations passes sandbox_id__eq to the info service. + + This verifies that the sandbox_id filter is correctly propagated through + the service layer to the underlying info service. + """ + from openhands.app_server.app_conversation.app_conversation_models import ( + AppConversationInfoPage, + ) + + # Create test data with different sandbox IDs + sandbox_id_alpha = 'sandbox-alpha-123' + sandbox_id_beta = 'sandbox-beta-456' + + conv_alpha = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id=sandbox_id_alpha, + title='Alpha Conversation', + ) + conv_beta = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id=sandbox_id_beta, + title='Beta Conversation', + ) + + # Mock the info service to return filtered results based on sandbox_id__eq + async def mock_search(sandbox_id__eq=None, **kwargs): + if sandbox_id__eq == sandbox_id_alpha: + return AppConversationInfoPage(items=[conv_alpha]) + elif sandbox_id__eq == sandbox_id_beta: + return AppConversationInfoPage(items=[conv_beta]) + else: + return AppConversationInfoPage(items=[conv_alpha, conv_beta]) + + self.mock_app_conversation_info_service.search_app_conversation_info = ( + AsyncMock(side_effect=mock_search) + ) + + # Mock sandbox service to return running status for sandbox lookups + self.mock_sandbox_service.batch_get_sandboxes = AsyncMock(return_value=[]) + + # Test filtering by sandbox_id_alpha + result = await self.service.search_app_conversations( + sandbox_id__eq=sandbox_id_alpha + ) + + # Verify the info service was called with the correct sandbox_id__eq + self.mock_app_conversation_info_service.search_app_conversation_info.assert_called() + call_kwargs = self.mock_app_conversation_info_service.search_app_conversation_info.call_args[ + 1 + ] + assert call_kwargs.get('sandbox_id__eq') == sandbox_id_alpha + + # Verify only alpha conversation is returned + assert len(result.items) == 1 + assert result.items[0].sandbox_id == sandbox_id_alpha + + @pytest.mark.asyncio + async def test_count_app_conversations_with_sandbox_id_filter(self): + """Test that count_app_conversations passes sandbox_id__eq to the info service. + + This verifies that the sandbox_id filter is correctly propagated through + the service layer to the underlying info service for count operations. + """ + sandbox_id = 'sandbox-count-test-789' + + # Mock the info service to return count based on sandbox_id__eq + async def mock_count(sandbox_id__eq=None, **kwargs): + if sandbox_id__eq == sandbox_id: + return 3 # 3 conversations match this sandbox + else: + return 10 # 10 total conversations + + self.mock_app_conversation_info_service.count_app_conversation_info = AsyncMock( + side_effect=mock_count + ) + + # Test counting with sandbox_id filter + result = await self.service.count_app_conversations(sandbox_id__eq=sandbox_id) + + # Verify the info service was called with the correct sandbox_id__eq + self.mock_app_conversation_info_service.count_app_conversation_info.assert_called_once() + call_kwargs = self.mock_app_conversation_info_service.count_app_conversation_info.call_args[ + 1 + ] + assert call_kwargs.get('sandbox_id__eq') == sandbox_id + + # Verify filtered count is returned + assert result == 3 + + @pytest.mark.asyncio + async def test_search_app_conversations_sandbox_id_filter_returns_empty(self): + """Test that search with non-matching sandbox_id returns empty results.""" + from openhands.app_server.app_conversation.app_conversation_models import ( + AppConversationInfoPage, + ) + + # Mock the info service to return empty for non-matching sandbox + self.mock_app_conversation_info_service.search_app_conversation_info = ( + AsyncMock(return_value=AppConversationInfoPage(items=[])) + ) + self.mock_sandbox_service.batch_get_sandboxes = AsyncMock(return_value=[]) + + # Test filtering by non-existent sandbox_id + result = await self.service.search_app_conversations( + sandbox_id__eq='non-existent-sandbox' + ) + + # Verify empty results + assert len(result.items) == 0 + class TestPluginHandling: """Test cases for plugin-related functionality in LiveStatusAppConversationService.""" From 922e3a24310168510dd96d206f16d79d80324595 Mon Sep 17 00:00:00 2001 From: chuckbutkus Date: Fri, 13 Mar 2026 14:32:58 -0400 Subject: [PATCH 18/92] Add AwsSharedEventService for shared conversations (#13141) Co-authored-by: openhands --- .../sharing/aws_shared_event_service.py | 171 ++++++ .../server/sharing/shared_event_router.py | 38 +- .../test_aws_shared_event_service.py | 555 ++++++++++++++++++ .../test_sharing/test_shared_event_router.py | 171 ++++++ openhands/app_server/config.py | 18 +- .../app_server/event/aws_event_service.py | 113 ++++ openhands/utils/environment.py | 33 ++ .../unit/app_server/test_aws_event_service.py | 213 +++++++ .../test_config_event_service_selection.py | 179 ++++++ tests/unit/utils/test_environment.py | 46 ++ 10 files changed, 1529 insertions(+), 8 deletions(-) create mode 100644 enterprise/server/sharing/aws_shared_event_service.py create mode 100644 enterprise/tests/unit/test_sharing/test_aws_shared_event_service.py create mode 100644 enterprise/tests/unit/test_sharing/test_shared_event_router.py create mode 100644 openhands/app_server/event/aws_event_service.py create mode 100644 tests/unit/app_server/test_aws_event_service.py create mode 100644 tests/unit/app_server/test_config_event_service_selection.py diff --git a/enterprise/server/sharing/aws_shared_event_service.py b/enterprise/server/sharing/aws_shared_event_service.py new file mode 100644 index 0000000000..9608f82ba7 --- /dev/null +++ b/enterprise/server/sharing/aws_shared_event_service.py @@ -0,0 +1,171 @@ +"""Implementation of SharedEventService for AWS S3. + +This implementation provides read-only access to events from shared conversations: +- Validates that the conversation is shared before returning events +- Uses existing EventService for actual event retrieval +- Uses SharedConversationInfoService for shared conversation validation + +Uses role-based authentication (no credentials needed). +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, AsyncGenerator +from uuid import UUID + +import boto3 +from fastapi import Request +from pydantic import Field +from server.sharing.shared_conversation_info_service import ( + SharedConversationInfoService, +) +from server.sharing.shared_event_service import ( + SharedEventService, + SharedEventServiceInjector, +) +from server.sharing.sql_shared_conversation_info_service import ( + SQLSharedConversationInfoService, +) + +from openhands.agent_server.models import EventPage, EventSortOrder +from openhands.app_server.event.aws_event_service import AwsEventService +from openhands.app_server.event.event_service import EventService +from openhands.app_server.event_callback.event_callback_models import EventKind +from openhands.app_server.services.injector import InjectorState +from openhands.sdk import Event + +logger = logging.getLogger(__name__) + + +@dataclass +class AwsSharedEventService(SharedEventService): + """Implementation of SharedEventService for AWS S3 that validates shared access. + + Uses role-based authentication (no credentials needed). + """ + + shared_conversation_info_service: SharedConversationInfoService + s3_client: Any + bucket_name: str + + async def get_event_service(self, conversation_id: UUID) -> EventService | None: + shared_conversation_info = ( + await self.shared_conversation_info_service.get_shared_conversation_info( + conversation_id + ) + ) + if shared_conversation_info is None: + return None + + return AwsEventService( + s3_client=self.s3_client, + bucket_name=self.bucket_name, + prefix=Path('users'), + user_id=shared_conversation_info.created_by_user_id, + app_conversation_info_service=None, + app_conversation_info_load_tasks={}, + ) + + async def get_shared_event( + self, conversation_id: UUID, event_id: UUID + ) -> Event | None: + """Given a conversation_id and event_id, retrieve an event if the conversation is shared.""" + # First check if the conversation is shared + event_service = await self.get_event_service(conversation_id) + if event_service is None: + return None + + # If conversation is shared, get the event + return await event_service.get_event(conversation_id, event_id) + + async def search_shared_events( + self, + conversation_id: UUID, + kind__eq: EventKind | None = None, + timestamp__gte: datetime | None = None, + timestamp__lt: datetime | None = None, + sort_order: EventSortOrder = EventSortOrder.TIMESTAMP, + page_id: str | None = None, + limit: int = 100, + ) -> EventPage: + """Search events for a specific shared conversation.""" + # First check if the conversation is shared + event_service = await self.get_event_service(conversation_id) + if event_service is None: + # Return empty page if conversation is not shared + return EventPage(items=[], next_page_id=None) + + # If conversation is shared, search events for this conversation + return await event_service.search_events( + conversation_id=conversation_id, + kind__eq=kind__eq, + timestamp__gte=timestamp__gte, + timestamp__lt=timestamp__lt, + sort_order=sort_order, + page_id=page_id, + limit=limit, + ) + + async def count_shared_events( + self, + conversation_id: UUID, + kind__eq: EventKind | None = None, + timestamp__gte: datetime | None = None, + timestamp__lt: datetime | None = None, + ) -> int: + """Count events for a specific shared conversation.""" + # First check if the conversation is shared + event_service = await self.get_event_service(conversation_id) + if event_service is None: + # Return empty page if conversation is not shared + return 0 + + # If conversation is shared, count events for this conversation + return await event_service.count_events( + conversation_id=conversation_id, + kind__eq=kind__eq, + timestamp__gte=timestamp__gte, + timestamp__lt=timestamp__lt, + ) + + +class AwsSharedEventServiceInjector(SharedEventServiceInjector): + bucket_name: str | None = Field( + default_factory=lambda: os.environ.get('FILE_STORE_PATH') + ) + + async def inject( + self, state: InjectorState, request: Request | None = None + ) -> AsyncGenerator[SharedEventService, None]: + # Define inline to prevent circular lookup + from openhands.app_server.config import get_db_session + + async with get_db_session(state, request) as db_session: + shared_conversation_info_service = SQLSharedConversationInfoService( + db_session=db_session + ) + + bucket_name = self.bucket_name + if bucket_name is None: + raise ValueError( + 'bucket_name is required. Set FILE_STORE_PATH environment variable.' + ) + + # Use role-based authentication - boto3 will automatically + # use IAM role credentials when running in AWS + s3_client = boto3.client( + 's3', + endpoint_url=os.getenv('AWS_S3_ENDPOINT'), + ) + + service = AwsSharedEventService( + shared_conversation_info_service=shared_conversation_info_service, + s3_client=s3_client, + bucket_name=bucket_name, + ) + yield service diff --git a/enterprise/server/sharing/shared_event_router.py b/enterprise/server/sharing/shared_event_router.py index f5b54c27ef..1f42d1d32e 100644 --- a/enterprise/server/sharing/shared_event_router.py +++ b/enterprise/server/sharing/shared_event_router.py @@ -5,19 +5,45 @@ from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, Query -from server.sharing.google_cloud_shared_event_service import ( - GoogleCloudSharedEventServiceInjector, +from server.sharing.shared_event_service import ( + SharedEventService, + SharedEventServiceInjector, ) -from server.sharing.shared_event_service import SharedEventService from openhands.agent_server.models import EventPage, EventSortOrder from openhands.app_server.event_callback.event_callback_models import EventKind from openhands.sdk import Event +from openhands.utils.environment import StorageProvider, get_storage_provider + + +def get_shared_event_service_injector() -> SharedEventServiceInjector: + """Get the appropriate SharedEventServiceInjector based on configuration. + + Uses get_storage_provider() to determine the storage backend. + See openhands.utils.environment for supported environment variables. + + Note: Shared events only support AWS and GCP storage. Filesystem storage + falls back to GCP for shared events. + """ + provider = get_storage_provider() + + if provider == StorageProvider.AWS: + from server.sharing.aws_shared_event_service import ( + AwsSharedEventServiceInjector, + ) + + return AwsSharedEventServiceInjector() + else: + # GCP is the default for shared events (including filesystem fallback) + from server.sharing.google_cloud_shared_event_service import ( + GoogleCloudSharedEventServiceInjector, + ) + + return GoogleCloudSharedEventServiceInjector() + router = APIRouter(prefix='/api/shared-events', tags=['Sharing']) -shared_event_service_dependency = Depends( - GoogleCloudSharedEventServiceInjector().depends -) +shared_event_service_dependency = Depends(get_shared_event_service_injector().depends) # Read methods diff --git a/enterprise/tests/unit/test_sharing/test_aws_shared_event_service.py b/enterprise/tests/unit/test_sharing/test_aws_shared_event_service.py new file mode 100644 index 0000000000..c90a741b1d --- /dev/null +++ b/enterprise/tests/unit/test_sharing/test_aws_shared_event_service.py @@ -0,0 +1,555 @@ +"""Tests for AwsSharedEventService.""" + +import os +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from server.sharing.aws_shared_event_service import ( + AwsSharedEventService, + AwsSharedEventServiceInjector, +) +from server.sharing.shared_conversation_info_service import ( + SharedConversationInfoService, +) +from server.sharing.shared_conversation_models import SharedConversation + +from openhands.agent_server.models import EventPage, EventSortOrder +from openhands.app_server.event.event_service import EventService +from openhands.sdk.llm import MetricsSnapshot +from openhands.sdk.llm.utils.metrics import TokenUsage + + +@pytest.fixture +def mock_shared_conversation_info_service(): + """Create a mock SharedConversationInfoService.""" + return AsyncMock(spec=SharedConversationInfoService) + + +@pytest.fixture +def mock_s3_client(): + """Create a mock S3 client.""" + return MagicMock() + + +@pytest.fixture +def mock_event_service(): + """Create a mock EventService for returned by get_event_service.""" + return AsyncMock(spec=EventService) + + +@pytest.fixture +def aws_shared_event_service(mock_shared_conversation_info_service, mock_s3_client): + """Create an AwsSharedEventService for testing.""" + return AwsSharedEventService( + shared_conversation_info_service=mock_shared_conversation_info_service, + s3_client=mock_s3_client, + bucket_name='test-bucket', + ) + + +@pytest.fixture +def sample_public_conversation(): + """Create a sample public conversation.""" + return SharedConversation( + id=uuid4(), + created_by_user_id='test_user', + sandbox_id='test_sandbox', + title='Test Public Conversation', + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + metrics=MetricsSnapshot( + accumulated_cost=0.0, + max_budget_per_task=10.0, + accumulated_token_usage=TokenUsage(), + ), + ) + + +@pytest.fixture +def sample_event(): + """Create a sample event.""" + # For testing purposes, we'll just use a mock that the EventPage can accept + # The actual event creation is complex and not the focus of these tests + return None + + +class TestAwsSharedEventService: + """Test cases for AwsSharedEventService.""" + + async def test_get_shared_event_returns_event_for_public_conversation( + self, + aws_shared_event_service, + mock_shared_conversation_info_service, + mock_event_service, + sample_public_conversation, + sample_event, + ): + """Test that get_shared_event returns an event for a public conversation.""" + conversation_id = sample_public_conversation.id + event_id = uuid4() + + # Mock the public conversation service to return a public conversation + mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation + + # Mock get_event_service to return our mock event service + aws_shared_event_service.get_event_service = AsyncMock( + return_value=mock_event_service + ) + + # Mock the event service to return an event + mock_event_service.get_event.return_value = sample_event + + # Call the method + result = await aws_shared_event_service.get_shared_event( + conversation_id, event_id + ) + + # Verify the result + assert result == sample_event + aws_shared_event_service.get_event_service.assert_called_once_with( + conversation_id + ) + mock_event_service.get_event.assert_called_once_with(conversation_id, event_id) + + async def test_get_shared_event_returns_none_for_private_conversation( + self, + aws_shared_event_service, + mock_shared_conversation_info_service, + mock_event_service, + ): + """Test that get_shared_event returns None for a private conversation.""" + conversation_id = uuid4() + event_id = uuid4() + + # Mock get_event_service to return None (private conversation) + aws_shared_event_service.get_event_service = AsyncMock(return_value=None) + + # Call the method + result = await aws_shared_event_service.get_shared_event( + conversation_id, event_id + ) + + # Verify the result + assert result is None + aws_shared_event_service.get_event_service.assert_called_once_with( + conversation_id + ) + # Event service should not be called since get_event_service returns None + mock_event_service.get_event.assert_not_called() + + async def test_search_shared_events_returns_events_for_public_conversation( + self, + aws_shared_event_service, + mock_shared_conversation_info_service, + mock_event_service, + sample_public_conversation, + sample_event, + ): + """Test that search_shared_events returns events for a public conversation.""" + conversation_id = sample_public_conversation.id + + # Mock get_event_service to return our mock event service + aws_shared_event_service.get_event_service = AsyncMock( + return_value=mock_event_service + ) + + # Mock the event service to return events + mock_event_page = EventPage(items=[], next_page_id=None) + mock_event_service.search_events.return_value = mock_event_page + + # Call the method + result = await aws_shared_event_service.search_shared_events( + conversation_id=conversation_id, + kind__eq='ActionEvent', + limit=10, + ) + + # Verify the result + assert result == mock_event_page + assert len(result.items) == 0 # Empty list as we mocked + + aws_shared_event_service.get_event_service.assert_called_once_with( + conversation_id + ) + mock_event_service.search_events.assert_called_once_with( + conversation_id=conversation_id, + kind__eq='ActionEvent', + timestamp__gte=None, + timestamp__lt=None, + sort_order=EventSortOrder.TIMESTAMP, + page_id=None, + limit=10, + ) + + async def test_search_shared_events_returns_empty_for_private_conversation( + self, + aws_shared_event_service, + mock_shared_conversation_info_service, + mock_event_service, + ): + """Test that search_shared_events returns empty page for a private conversation.""" + conversation_id = uuid4() + + # Mock get_event_service to return None (private conversation) + aws_shared_event_service.get_event_service = AsyncMock(return_value=None) + + # Call the method + result = await aws_shared_event_service.search_shared_events( + conversation_id=conversation_id, + limit=10, + ) + + # Verify the result + assert isinstance(result, EventPage) + assert len(result.items) == 0 + assert result.next_page_id is None + + aws_shared_event_service.get_event_service.assert_called_once_with( + conversation_id + ) + # Event service should not be called + mock_event_service.search_events.assert_not_called() + + async def test_count_shared_events_returns_count_for_public_conversation( + self, + aws_shared_event_service, + mock_shared_conversation_info_service, + mock_event_service, + sample_public_conversation, + ): + """Test that count_shared_events returns count for a public conversation.""" + conversation_id = sample_public_conversation.id + + # Mock get_event_service to return our mock event service + aws_shared_event_service.get_event_service = AsyncMock( + return_value=mock_event_service + ) + + # Mock the event service to return a count + mock_event_service.count_events.return_value = 5 + + # Call the method + result = await aws_shared_event_service.count_shared_events( + conversation_id=conversation_id, + kind__eq='ActionEvent', + ) + + # Verify the result + assert result == 5 + + aws_shared_event_service.get_event_service.assert_called_once_with( + conversation_id + ) + mock_event_service.count_events.assert_called_once_with( + conversation_id=conversation_id, + kind__eq='ActionEvent', + timestamp__gte=None, + timestamp__lt=None, + ) + + async def test_count_shared_events_returns_zero_for_private_conversation( + self, + aws_shared_event_service, + mock_shared_conversation_info_service, + mock_event_service, + ): + """Test that count_shared_events returns 0 for a private conversation.""" + conversation_id = uuid4() + + # Mock get_event_service to return None (private conversation) + aws_shared_event_service.get_event_service = AsyncMock(return_value=None) + + # Call the method + result = await aws_shared_event_service.count_shared_events( + conversation_id=conversation_id, + ) + + # Verify the result + assert result == 0 + + aws_shared_event_service.get_event_service.assert_called_once_with( + conversation_id + ) + # Event service should not be called + mock_event_service.count_events.assert_not_called() + + async def test_batch_get_shared_events_returns_events_for_public_conversation( + self, + aws_shared_event_service, + mock_shared_conversation_info_service, + mock_event_service, + sample_public_conversation, + sample_event, + ): + """Test that batch_get_shared_events returns events for a public conversation.""" + conversation_id = sample_public_conversation.id + event_ids = [uuid4() for _ in range(3)] + + # Mock get_event_service to return our mock event service + aws_shared_event_service.get_event_service = AsyncMock( + return_value=mock_event_service + ) + + # Mock the event service to return events + mock_event_service.get_event.return_value = sample_event + + # Call the method + results = await aws_shared_event_service.batch_get_shared_events( + conversation_id, event_ids + ) + + # Verify the results + assert len(results) == 3 + assert all(result == sample_event for result in results) + + +class TestAwsSharedEventServiceGetEventService: + """Test cases for AwsSharedEventService.get_event_service method.""" + + async def test_get_event_service_returns_event_service_for_shared_conversation( + self, + aws_shared_event_service, + mock_shared_conversation_info_service, + sample_public_conversation, + ): + """Test that get_event_service returns an EventService for a shared conversation.""" + conversation_id = sample_public_conversation.id + + # Mock the shared conversation info service to return a shared conversation + mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation + + # Call the method + result = await aws_shared_event_service.get_event_service(conversation_id) + + # Verify the result + assert result is not None + mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with( + conversation_id + ) + + async def test_get_event_service_returns_none_for_non_shared_conversation( + self, + aws_shared_event_service, + mock_shared_conversation_info_service, + ): + """Test that get_event_service returns None for a non-shared conversation.""" + conversation_id = uuid4() + + # Mock the shared conversation info service to return None + mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None + + # Call the method + result = await aws_shared_event_service.get_event_service(conversation_id) + + # Verify the result + assert result is None + mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with( + conversation_id + ) + + +class TestAwsSharedEventServiceInjector: + """Test cases for AwsSharedEventServiceInjector.""" + + def test_bucket_name_from_environment_variable(self): + """Test that bucket_name is read from FILE_STORE_PATH environment variable.""" + test_bucket_name = 'test-bucket-name' + with patch.dict(os.environ, {'FILE_STORE_PATH': test_bucket_name}): + # Create a new injector instance to pick up the environment variable + # Note: The class attribute is evaluated at class definition time, + # so we need to test that the attribute exists and can be overridden + injector = AwsSharedEventServiceInjector() + injector.bucket_name = os.environ.get('FILE_STORE_PATH') + assert injector.bucket_name == test_bucket_name + + def test_bucket_name_default_value_when_env_not_set(self): + """Test that bucket_name is None when FILE_STORE_PATH is not set.""" + with patch.dict(os.environ, {}, clear=True): + # Remove FILE_STORE_PATH if it exists + os.environ.pop('FILE_STORE_PATH', None) + injector = AwsSharedEventServiceInjector() + # The bucket_name will be whatever was set at class definition time + # or None if FILE_STORE_PATH was not set when the class was defined + assert hasattr(injector, 'bucket_name') + + async def test_injector_yields_aws_shared_event_service(self): + """Test that the injector yields an AwsSharedEventService instance.""" + mock_state = MagicMock() + mock_request = MagicMock() + mock_db_session = AsyncMock() + + # Create the injector + injector = AwsSharedEventServiceInjector() + injector.bucket_name = 'test-bucket' + + # Mock the get_db_session context manager + mock_db_context = AsyncMock() + mock_db_context.__aenter__.return_value = mock_db_session + mock_db_context.__aexit__.return_value = None + + # Mock boto3.client + mock_s3_client = MagicMock() + + with ( + patch( + 'server.sharing.aws_shared_event_service.boto3.client', + return_value=mock_s3_client, + ), + patch( + 'openhands.app_server.config.get_db_session', + return_value=mock_db_context, + ), + ): + # Call the inject method + async for service in injector.inject(mock_state, mock_request): + # Verify the service is an instance of AwsSharedEventService + assert isinstance(service, AwsSharedEventService) + assert service.s3_client == mock_s3_client + assert service.bucket_name == 'test-bucket' + + async def test_injector_uses_bucket_name_from_instance(self): + """Test that the injector uses the bucket_name from the instance.""" + mock_state = MagicMock() + mock_request = MagicMock() + mock_db_session = AsyncMock() + + # Create the injector with a specific bucket name + injector = AwsSharedEventServiceInjector() + injector.bucket_name = 'my-custom-bucket' + + # Mock the get_db_session context manager + mock_db_context = AsyncMock() + mock_db_context.__aenter__.return_value = mock_db_session + mock_db_context.__aexit__.return_value = None + + # Mock boto3.client + mock_s3_client = MagicMock() + + with ( + patch( + 'server.sharing.aws_shared_event_service.boto3.client', + return_value=mock_s3_client, + ), + patch( + 'openhands.app_server.config.get_db_session', + return_value=mock_db_context, + ), + ): + # Call the inject method + async for service in injector.inject(mock_state, mock_request): + assert service.bucket_name == 'my-custom-bucket' + + async def test_injector_creates_sql_shared_conversation_info_service(self): + """Test that the injector creates SQLSharedConversationInfoService with db_session.""" + mock_state = MagicMock() + mock_request = MagicMock() + mock_db_session = AsyncMock() + + # Create the injector + injector = AwsSharedEventServiceInjector() + injector.bucket_name = 'test-bucket' + + # Mock the get_db_session context manager + mock_db_context = AsyncMock() + mock_db_context.__aenter__.return_value = mock_db_session + mock_db_context.__aexit__.return_value = None + + # Mock boto3.client + mock_s3_client = MagicMock() + + with ( + patch( + 'server.sharing.aws_shared_event_service.boto3.client', + return_value=mock_s3_client, + ), + patch( + 'openhands.app_server.config.get_db_session', + return_value=mock_db_context, + ), + patch( + 'server.sharing.aws_shared_event_service.SQLSharedConversationInfoService' + ) as mock_sql_service_class, + ): + mock_sql_service = MagicMock() + mock_sql_service_class.return_value = mock_sql_service + + # Call the inject method + async for service in injector.inject(mock_state, mock_request): + # Verify the service has the correct shared_conversation_info_service + assert service.shared_conversation_info_service == mock_sql_service + + # Verify SQLSharedConversationInfoService was created with db_session + mock_sql_service_class.assert_called_once_with(db_session=mock_db_session) + + async def test_injector_works_without_request(self): + """Test that the injector works when request is None.""" + mock_state = MagicMock() + mock_db_session = AsyncMock() + + # Create the injector + injector = AwsSharedEventServiceInjector() + injector.bucket_name = 'test-bucket' + + # Mock the get_db_session context manager + mock_db_context = AsyncMock() + mock_db_context.__aenter__.return_value = mock_db_session + mock_db_context.__aexit__.return_value = None + + # Mock boto3.client + mock_s3_client = MagicMock() + + with ( + patch( + 'server.sharing.aws_shared_event_service.boto3.client', + return_value=mock_s3_client, + ), + patch( + 'openhands.app_server.config.get_db_session', + return_value=mock_db_context, + ), + ): + # Call the inject method with request=None + async for service in injector.inject(mock_state, request=None): + assert isinstance(service, AwsSharedEventService) + + async def test_injector_uses_role_based_authentication(self): + """Test that the injector uses role-based authentication (no explicit credentials).""" + mock_state = MagicMock() + mock_request = MagicMock() + mock_db_session = AsyncMock() + + # Create the injector + injector = AwsSharedEventServiceInjector() + injector.bucket_name = 'test-bucket' + + # Mock the get_db_session context manager + mock_db_context = AsyncMock() + mock_db_context.__aenter__.return_value = mock_db_session + mock_db_context.__aexit__.return_value = None + + # Mock boto3.client + mock_s3_client = MagicMock() + + with ( + patch( + 'server.sharing.aws_shared_event_service.boto3.client', + return_value=mock_s3_client, + ) as mock_boto3_client, + patch( + 'openhands.app_server.config.get_db_session', + return_value=mock_db_context, + ), + patch.dict(os.environ, {'AWS_S3_ENDPOINT': 'https://s3.example.com'}), + ): + # Call the inject method + async for service in injector.inject(mock_state, mock_request): + pass + + # Verify boto3.client was called with 's3' and endpoint_url + # but without explicit credentials (role-based auth) + mock_boto3_client.assert_called_once_with( + 's3', + endpoint_url='https://s3.example.com', + ) diff --git a/enterprise/tests/unit/test_sharing/test_shared_event_router.py b/enterprise/tests/unit/test_sharing/test_shared_event_router.py new file mode 100644 index 0000000000..3182a549af --- /dev/null +++ b/enterprise/tests/unit/test_sharing/test_shared_event_router.py @@ -0,0 +1,171 @@ +"""Tests for shared_event_router provider selection. + +This module tests the get_shared_event_service_injector function which +determines which SharedEventServiceInjector to use based on environment variables. +""" + +import os +from unittest.mock import patch + +from server.sharing.aws_shared_event_service import AwsSharedEventServiceInjector +from server.sharing.google_cloud_shared_event_service import ( + GoogleCloudSharedEventServiceInjector, +) +from server.sharing.shared_event_router import get_shared_event_service_injector + + +class TestGetSharedEventServiceInjector: + """Test cases for get_shared_event_service_injector function.""" + + def test_defaults_to_google_cloud_when_no_env_set(self): + """Test that GoogleCloudSharedEventServiceInjector is used when no env is set.""" + with patch.dict( + os.environ, + {}, + clear=True, + ): + os.environ.pop('SHARED_EVENT_STORAGE_PROVIDER', None) + os.environ.pop('FILE_STORE', None) + + injector = get_shared_event_service_injector() + + assert isinstance(injector, GoogleCloudSharedEventServiceInjector) + + def test_uses_google_cloud_when_file_store_google_cloud(self): + """Test that GoogleCloudSharedEventServiceInjector is used when FILE_STORE=google_cloud.""" + with patch.dict( + os.environ, + { + 'FILE_STORE': 'google_cloud', + }, + clear=True, + ): + os.environ.pop('SHARED_EVENT_STORAGE_PROVIDER', None) + + injector = get_shared_event_service_injector() + + assert isinstance(injector, GoogleCloudSharedEventServiceInjector) + + def test_uses_aws_when_provider_aws(self): + """Test that AwsSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=aws.""" + with patch.dict( + os.environ, + { + 'SHARED_EVENT_STORAGE_PROVIDER': 'aws', + }, + clear=True, + ): + injector = get_shared_event_service_injector() + + assert isinstance(injector, AwsSharedEventServiceInjector) + + def test_uses_gcp_when_provider_gcp(self): + """Test that GoogleCloudSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=gcp.""" + with patch.dict( + os.environ, + { + 'SHARED_EVENT_STORAGE_PROVIDER': 'gcp', + }, + clear=True, + ): + injector = get_shared_event_service_injector() + + assert isinstance(injector, GoogleCloudSharedEventServiceInjector) + + def test_uses_gcp_when_provider_google_cloud(self): + """Test that GoogleCloudSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=google_cloud.""" + with patch.dict( + os.environ, + { + 'SHARED_EVENT_STORAGE_PROVIDER': 'google_cloud', + }, + clear=True, + ): + injector = get_shared_event_service_injector() + + assert isinstance(injector, GoogleCloudSharedEventServiceInjector) + + def test_provider_takes_precedence_over_file_store(self): + """Test that SHARED_EVENT_STORAGE_PROVIDER takes precedence over FILE_STORE.""" + with patch.dict( + os.environ, + { + 'SHARED_EVENT_STORAGE_PROVIDER': 'aws', + 'FILE_STORE': 'google_cloud', + }, + clear=True, + ): + injector = get_shared_event_service_injector() + + # Should use AWS because SHARED_EVENT_STORAGE_PROVIDER takes precedence + assert isinstance(injector, AwsSharedEventServiceInjector) + + def test_provider_gcp_takes_precedence_over_file_store_s3(self): + """Test that SHARED_EVENT_STORAGE_PROVIDER=gcp takes precedence over FILE_STORE=s3.""" + with patch.dict( + os.environ, + { + 'SHARED_EVENT_STORAGE_PROVIDER': 'gcp', + 'FILE_STORE': 's3', + }, + clear=True, + ): + injector = get_shared_event_service_injector() + + # Should use GCP because SHARED_EVENT_STORAGE_PROVIDER takes precedence + assert isinstance(injector, GoogleCloudSharedEventServiceInjector) + + def test_provider_is_case_insensitive_aws(self): + """Test that SHARED_EVENT_STORAGE_PROVIDER is case insensitive for AWS.""" + with patch.dict( + os.environ, + { + 'SHARED_EVENT_STORAGE_PROVIDER': 'AWS', + }, + clear=True, + ): + injector = get_shared_event_service_injector() + + assert isinstance(injector, AwsSharedEventServiceInjector) + + def test_provider_is_case_insensitive_gcp(self): + """Test that SHARED_EVENT_STORAGE_PROVIDER is case insensitive for GCP.""" + with patch.dict( + os.environ, + { + 'SHARED_EVENT_STORAGE_PROVIDER': 'GCP', + }, + clear=True, + ): + injector = get_shared_event_service_injector() + + assert isinstance(injector, GoogleCloudSharedEventServiceInjector) + + def test_unknown_provider_defaults_to_google_cloud(self): + """Test that unknown provider defaults to GoogleCloudSharedEventServiceInjector.""" + with patch.dict( + os.environ, + { + 'SHARED_EVENT_STORAGE_PROVIDER': 'unknown_provider', + }, + clear=True, + ): + injector = get_shared_event_service_injector() + + # Should default to GCP for unknown providers + assert isinstance(injector, GoogleCloudSharedEventServiceInjector) + + def test_empty_provider_falls_back_to_file_store(self): + """Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE.""" + with patch.dict( + os.environ, + { + 'SHARED_EVENT_STORAGE_PROVIDER': '', + 'FILE_STORE': 'google_cloud', + }, + clear=True, + ): + injector = get_shared_event_service_injector() + + # Should default to GCP for unknown providers + assert isinstance(injector, GoogleCloudSharedEventServiceInjector) diff --git a/openhands/app_server/config.py b/openhands/app_server/config.py index 0cc42a471a..8c0ded6d2e 100644 --- a/openhands/app_server/config.py +++ b/openhands/app_server/config.py @@ -56,6 +56,7 @@ from openhands.app_server.web_client.web_client_config_injector import ( ) from openhands.sdk.utils.models import OpenHandsModel from openhands.server.types import AppMode +from openhands.utils.environment import StorageProvider, get_storage_provider def get_default_persistence_dir() -> Path: @@ -140,6 +141,9 @@ def config_from_env() -> AppServerConfig: from openhands.app_server.app_conversation.sql_app_conversation_start_task_service import ( # noqa: E501 SQLAppConversationStartTaskServiceInjector, ) + from openhands.app_server.event.aws_event_service import ( + AwsEventServiceInjector, + ) from openhands.app_server.event.filesystem_event_service import ( FilesystemEventServiceInjector, ) @@ -174,8 +178,18 @@ def config_from_env() -> AppServerConfig: config: AppServerConfig = from_env(AppServerConfig, 'OH') # type: ignore if config.event is None: - if os.environ.get('FILE_STORE') == 'google_cloud': - # Legacy V0 google cloud storage configuration + provider = get_storage_provider() + + if provider == StorageProvider.AWS: + # AWS S3 storage configuration + bucket_name = os.environ.get('FILE_STORE_PATH') + if not bucket_name: + raise ValueError( + 'FILE_STORE_PATH environment variable is required for S3 storage' + ) + config.event = AwsEventServiceInjector(bucket_name=bucket_name) + elif provider == StorageProvider.GCP: + # Google Cloud storage configuration config.event = GoogleCloudEventServiceInjector( bucket_name=os.environ.get('FILE_STORE_PATH') ) diff --git a/openhands/app_server/event/aws_event_service.py b/openhands/app_server/event/aws_event_service.py new file mode 100644 index 0000000000..fd6cf4017d --- /dev/null +++ b/openhands/app_server/event/aws_event_service.py @@ -0,0 +1,113 @@ +"""AWS S3-based EventService implementation. + +This implementation uses role-based authentication (no credentials needed). +""" + +import json +import logging +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, AsyncGenerator + +import boto3 +import botocore.exceptions +from fastapi import Request + +from openhands.app_server.config import get_app_conversation_info_service +from openhands.app_server.event.event_service import EventService, EventServiceInjector +from openhands.app_server.event.event_service_base import EventServiceBase +from openhands.app_server.services.injector import InjectorState +from openhands.sdk import Event + +_logger = logging.getLogger(__name__) + + +@dataclass +class AwsEventService(EventServiceBase): + """AWS S3-based implementation of EventService. + + Uses role-based authentication, so no explicit credentials are needed. + """ + + s3_client: Any + bucket_name: str + + def _load_event(self, path: Path) -> Event | None: + """Get the event at the path given.""" + try: + response = self.s3_client.get_object(Bucket=self.bucket_name, Key=str(path)) + with response['Body'] as stream: + json_data = stream.read().decode('utf-8') + event = Event.model_validate_json(json_data) + return event + except botocore.exceptions.ClientError as e: + if e.response['Error']['Code'] == 'NoSuchKey': + return None + _logger.exception(f'Error reading event from {path}') + return None + except Exception: + _logger.exception(f'Error reading event from {path}') + return None + + def _store_event(self, path: Path, event: Event): + """Store the event given at the path given.""" + data = event.model_dump(mode='json') + json_str = json.dumps(data, indent=2) + self.s3_client.put_object( + Bucket=self.bucket_name, + Key=str(path), + Body=json_str.encode('utf-8'), + ) + + def _search_paths(self, prefix: Path, page_id: str | None = None) -> list[Path]: + """Search paths.""" + kwargs: dict[str, Any] = { + 'Bucket': self.bucket_name, + 'Prefix': str(prefix), + } + if page_id: + kwargs['ContinuationToken'] = page_id + + response = self.s3_client.list_objects_v2(**kwargs) + contents = response.get('Contents', []) + paths = [Path(obj['Key']) for obj in contents] + return paths + + +class AwsEventServiceInjector(EventServiceInjector): + bucket_name: str + prefix: Path = Path('users') + + async def inject( + self, state: InjectorState, request: Request | None = None + ) -> AsyncGenerator[EventService, None]: + from openhands.app_server.config import ( + get_user_context, + ) + + async with ( + get_user_context(state, request) as user_context, + get_app_conversation_info_service( + state, request + ) as app_conversation_info_service, + ): + user_id = await user_context.get_user_id() + + bucket_name = self.bucket_name + + # Use role-based authentication - boto3 will automatically + # use IAM role credentials when running in AWS + s3_client = boto3.client( + 's3', + endpoint_url=os.getenv('AWS_S3_ENDPOINT'), + ) + + yield AwsEventService( + prefix=self.prefix, + user_id=user_id, + app_conversation_info_service=app_conversation_info_service, + s3_client=s3_client, + bucket_name=bucket_name, + app_conversation_info_load_tasks={}, + ) diff --git a/openhands/utils/environment.py b/openhands/utils/environment.py index 140c1d385d..7c28bcfa89 100644 --- a/openhands/utils/environment.py +++ b/openhands/utils/environment.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from enum import Enum from functools import lru_cache from pathlib import Path @@ -9,6 +10,38 @@ _LEMONADE_PROVIDER_NAME = 'lemonade' _LEMONADE_MODEL_PREFIX = 'lemonade/' +class StorageProvider(str, Enum): + """Storage provider types for event and shared event storage.""" + + AWS = 'aws' + GCP = 'gcp' + FILESYSTEM = 'filesystem' + + +def get_storage_provider() -> StorageProvider: + """Get the storage provider based on environment variables. + + Determines the storage provider from environment configuration: + - SHARED_EVENT_STORAGE_PROVIDER: Primary setting, supports 'aws', 'gcp', 'google_cloud' + - FILE_STORE: Legacy fallback, supports 'google_cloud' + + Returns: + StorageProvider: The configured storage provider (AWS, GCP, or FILESYSTEM) + """ + provider = os.environ.get('SHARED_EVENT_STORAGE_PROVIDER', '').lower() + + # If not explicitly set, fall back to FILE_STORE + if not provider: + provider = os.environ.get('FILE_STORE', '').lower() + + if provider == 'aws': + return StorageProvider.AWS + elif provider in ('gcp', 'google_cloud'): + return StorageProvider.GCP + else: + return StorageProvider.FILESYSTEM + + @lru_cache(maxsize=1) def is_running_in_docker() -> bool: """Best-effort detection for Docker containers.""" diff --git a/tests/unit/app_server/test_aws_event_service.py b/tests/unit/app_server/test_aws_event_service.py new file mode 100644 index 0000000000..1b1f60e80b --- /dev/null +++ b/tests/unit/app_server/test_aws_event_service.py @@ -0,0 +1,213 @@ +"""Tests for AwsEventService. + +This module tests the AWS S3-based implementation of EventService, +focusing on search functionality and S3 operations. +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock +from uuid import uuid4 + +import botocore.exceptions +import pytest + +from openhands.app_server.event.aws_event_service import ( + AwsEventService, + AwsEventServiceInjector, +) +from openhands.sdk.event import PauseEvent, TokenEvent + + +@pytest.fixture +def mock_s3_client(): + """Create a mock S3 client.""" + return MagicMock() + + +@pytest.fixture +def service(mock_s3_client) -> AwsEventService: + """Create an AwsEventService instance for testing.""" + return AwsEventService( + prefix=Path('users'), + user_id='test_user', + app_conversation_info_service=None, + s3_client=mock_s3_client, + bucket_name='test-bucket', + app_conversation_info_load_tasks={}, + ) + + +@pytest.fixture +def service_no_user(mock_s3_client) -> AwsEventService: + """Create an AwsEventService instance without user_id.""" + return AwsEventService( + prefix=Path('users'), + user_id=None, + app_conversation_info_service=None, + s3_client=mock_s3_client, + bucket_name='test-bucket', + app_conversation_info_load_tasks={}, + ) + + +def create_token_event() -> TokenEvent: + """Helper to create a TokenEvent for testing.""" + return TokenEvent( + source='agent', prompt_token_ids=[1, 2], response_token_ids=[3, 4] + ) + + +def create_pause_event() -> PauseEvent: + """Helper to create a PauseEvent for testing.""" + return PauseEvent(source='user') + + +class TestAwsEventServiceLoadEvent: + """Test cases for _load_event method.""" + + def test_load_event_success(self, service: AwsEventService, mock_s3_client): + """Test that _load_event successfully loads an event from S3.""" + event = create_token_event() + json_data = event.model_dump_json() + + # Mock the S3 response + mock_body = MagicMock() + mock_body.read.return_value = json_data.encode('utf-8') + mock_body.__enter__ = MagicMock(return_value=mock_body) + mock_body.__exit__ = MagicMock(return_value=False) + mock_s3_client.get_object.return_value = {'Body': mock_body} + + result = service._load_event(Path('some/path/event.json')) + + assert result is not None + assert result.kind == 'TokenEvent' + mock_s3_client.get_object.assert_called_once_with( + Bucket='test-bucket', Key='some/path/event.json' + ) + + def test_load_event_not_found(self, service: AwsEventService, mock_s3_client): + """Test that _load_event returns None when event doesn't exist.""" + error_response = {'Error': {'Code': 'NoSuchKey', 'Message': 'Not found'}} + mock_s3_client.get_object.side_effect = botocore.exceptions.ClientError( + error_response, 'GetObject' + ) + + result = service._load_event(Path('some/path/missing.json')) + + assert result is None + + def test_load_event_other_error(self, service: AwsEventService, mock_s3_client): + """Test that _load_event returns None and logs error on other S3 errors.""" + error_response = {'Error': {'Code': 'AccessDenied', 'Message': 'Access denied'}} + mock_s3_client.get_object.side_effect = botocore.exceptions.ClientError( + error_response, 'GetObject' + ) + + result = service._load_event(Path('some/path/denied.json')) + + assert result is None + + +class TestAwsEventServiceStoreEvent: + """Test cases for _store_event method.""" + + def test_store_event_success(self, service: AwsEventService, mock_s3_client): + """Test that _store_event successfully stores an event to S3.""" + event = create_token_event() + + service._store_event(Path('some/path/event.json'), event) + + mock_s3_client.put_object.assert_called_once() + call_args = mock_s3_client.put_object.call_args + assert call_args.kwargs['Bucket'] == 'test-bucket' + assert call_args.kwargs['Key'] == 'some/path/event.json' + # Verify the body is valid JSON + body = call_args.kwargs['Body'].decode('utf-8') + data = json.loads(body) + assert data['kind'] == 'TokenEvent' + + +class TestAwsEventServiceSearchPaths: + """Test cases for _search_paths method.""" + + def test_search_paths_returns_paths(self, service: AwsEventService, mock_s3_client): + """Test that _search_paths returns paths from S3.""" + mock_s3_client.list_objects_v2.return_value = { + 'Contents': [ + {'Key': 'users/test_user/v1_conversations/abc123/event1.json'}, + {'Key': 'users/test_user/v1_conversations/abc123/event2.json'}, + ] + } + + result = service._search_paths(Path('users/test_user/v1_conversations/abc123')) + + assert len(result) == 2 + assert result[0] == Path('users/test_user/v1_conversations/abc123/event1.json') + assert result[1] == Path('users/test_user/v1_conversations/abc123/event2.json') + + def test_search_paths_empty_bucket(self, service: AwsEventService, mock_s3_client): + """Test that _search_paths handles empty results.""" + mock_s3_client.list_objects_v2.return_value = {} + + result = service._search_paths(Path('users/test_user/v1_conversations/abc123')) + + assert len(result) == 0 + + def test_search_paths_with_page_id(self, service: AwsEventService, mock_s3_client): + """Test that _search_paths uses continuation token.""" + mock_s3_client.list_objects_v2.return_value = { + 'Contents': [{'Key': 'event.json'}] + } + + service._search_paths(Path('prefix'), page_id='continuation_token') + + mock_s3_client.list_objects_v2.assert_called_once_with( + Bucket='test-bucket', + Prefix='prefix', + ContinuationToken='continuation_token', + ) + + +class TestAwsEventServiceIntegration: + """Integration tests for AwsEventService.""" + + @pytest.mark.asyncio + async def test_get_conversation_path_with_user_id(self, service: AwsEventService): + """Test conversation path generation with user_id.""" + conversation_id = uuid4() + + path = await service.get_conversation_path(conversation_id) + + assert 'users' in str(path) + assert 'test_user' in str(path) + assert 'v1_conversations' in str(path) + assert conversation_id.hex in str(path) + + @pytest.mark.asyncio + async def test_get_conversation_path_without_user_id( + self, service_no_user: AwsEventService + ): + """Test conversation path generation without user_id.""" + conversation_id = uuid4() + + path = await service_no_user.get_conversation_path(conversation_id) + + assert 'users' in str(path) + assert 'test_user' not in str(path) + assert 'v1_conversations' in str(path) + assert conversation_id.hex in str(path) + + +class TestAwsEventServiceInjector: + """Test cases for AwsEventServiceInjector.""" + + def test_injector_has_bucket_name(self): + """Test that injector has bucket_name attribute.""" + injector = AwsEventServiceInjector(bucket_name='my-bucket') + assert injector.bucket_name == 'my-bucket' + + def test_injector_has_default_prefix(self): + """Test that injector has default prefix.""" + injector = AwsEventServiceInjector(bucket_name='my-bucket') + assert injector.prefix == Path('users') diff --git a/tests/unit/app_server/test_config_event_service_selection.py b/tests/unit/app_server/test_config_event_service_selection.py new file mode 100644 index 0000000000..85362c9013 --- /dev/null +++ b/tests/unit/app_server/test_config_event_service_selection.py @@ -0,0 +1,179 @@ +"""Tests for config_from_env event service provider selection. + +This module tests the event service provider selection logic in config_from_env, +which determines which EventServiceInjector to use based on environment variables. +""" + +import os +from unittest.mock import patch + +import pytest + +# Note: We need to clear the global config cache before each test +# to ensure environment variable changes take effect + + +@pytest.fixture(autouse=True) +def reset_global_config(): + """Reset the global config before and after each test.""" + import openhands.app_server.config as config_module + + original_config = config_module._global_config + config_module._global_config = None + yield + config_module._global_config = original_config + + +def _get_clean_env(): + """Get a base environment dict with essential system vars preserved.""" + # Preserve essential system environment variables + env = {} + for key in ['PATH', 'HOME', 'PYTHONPATH', 'VIRTUAL_ENV', 'TMPDIR', 'TMP', 'TEMP']: + if key in os.environ: + env[key] = os.environ[key] + return env + + +class TestConfigFromEnvEventServiceSelection: + """Test cases for event service provider selection in config_from_env.""" + + def test_defaults_to_filesystem_when_no_env_set(self): + """Test that FilesystemEventServiceInjector is used when no FILE_STORE is set.""" + from openhands.app_server.config import config_from_env + from openhands.app_server.event.filesystem_event_service import ( + FilesystemEventServiceInjector, + ) + + env = _get_clean_env() + with patch.dict(os.environ, env, clear=True): + config = config_from_env() + assert isinstance(config.event, FilesystemEventServiceInjector) + + def test_uses_google_cloud_when_file_store_google_cloud(self): + """Test that GoogleCloudEventServiceInjector is used when FILE_STORE=google_cloud.""" + from openhands.app_server.config import config_from_env + from openhands.app_server.event.google_cloud_event_service import ( + GoogleCloudEventServiceInjector, + ) + + env = _get_clean_env() + env['FILE_STORE'] = 'google_cloud' + env['FILE_STORE_PATH'] = 'test-gcp-bucket' + + with patch.dict(os.environ, env, clear=True): + config = config_from_env() + + assert isinstance(config.event, GoogleCloudEventServiceInjector) + assert config.event.bucket_name == 'test-gcp-bucket' + + def test_uses_gcp_when_provider_gcp(self): + """Test that GoogleCloudEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=gcp.""" + from openhands.app_server.config import config_from_env + from openhands.app_server.event.google_cloud_event_service import ( + GoogleCloudEventServiceInjector, + ) + + env = _get_clean_env() + env['SHARED_EVENT_STORAGE_PROVIDER'] = 'gcp' + env['FILE_STORE_PATH'] = 'test-gcp-bucket' + + with patch.dict(os.environ, env, clear=True): + config = config_from_env() + assert isinstance(config.event, GoogleCloudEventServiceInjector) + + def test_uses_aws_when_provider_aws(self): + """Test that AwsEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=aws.""" + from openhands.app_server.config import config_from_env + from openhands.app_server.event.aws_event_service import ( + AwsEventServiceInjector, + ) + + env = _get_clean_env() + env['SHARED_EVENT_STORAGE_PROVIDER'] = 'aws' + env['FILE_STORE_PATH'] = 'test-aws-bucket' + + with patch.dict(os.environ, env, clear=True): + config = config_from_env() + + assert isinstance(config.event, AwsEventServiceInjector) + assert config.event.bucket_name == 'test-aws-bucket' + + def test_aws_requires_file_store_path(self): + """Test that AWS provider requires FILE_STORE_PATH to be set.""" + from openhands.app_server.config import config_from_env + + env = _get_clean_env() + env['SHARED_EVENT_STORAGE_PROVIDER'] = 'aws' + + with patch.dict(os.environ, env, clear=True): + with pytest.raises(ValueError) as exc_info: + config_from_env() + + assert 'FILE_STORE_PATH' in str(exc_info.value) + assert 'required' in str(exc_info.value).lower() + + def test_provider_takes_precedence_over_file_store(self): + """Test that SHARED_EVENT_STORAGE_PROVIDER takes precedence over FILE_STORE.""" + from openhands.app_server.config import config_from_env + from openhands.app_server.event.aws_event_service import ( + AwsEventServiceInjector, + ) + + env = _get_clean_env() + env['SHARED_EVENT_STORAGE_PROVIDER'] = 'aws' + env['FILE_STORE'] = 'google_cloud' + env['FILE_STORE_PATH'] = 'test-bucket' + + with patch.dict(os.environ, env, clear=True): + config = config_from_env() + + # Should use AWS because SHARED_EVENT_STORAGE_PROVIDER takes precedence + assert isinstance(config.event, AwsEventServiceInjector) + + def test_provider_gcp_takes_precedence_over_file_store_s3(self): + """Test that SHARED_EVENT_STORAGE_PROVIDER=gcp takes precedence over FILE_STORE=s3.""" + from openhands.app_server.config import config_from_env + from openhands.app_server.event.google_cloud_event_service import ( + GoogleCloudEventServiceInjector, + ) + + env = _get_clean_env() + env['SHARED_EVENT_STORAGE_PROVIDER'] = 'gcp' + env['FILE_STORE'] = 's3' + env['FILE_STORE_PATH'] = 'test-bucket' + + with patch.dict(os.environ, env, clear=True): + config = config_from_env() + + # Should use GCP because SHARED_EVENT_STORAGE_PROVIDER takes precedence + assert isinstance(config.event, GoogleCloudEventServiceInjector) + + def test_provider_is_case_insensitive(self): + """Test that SHARED_EVENT_STORAGE_PROVIDER is case insensitive.""" + from openhands.app_server.config import config_from_env + from openhands.app_server.event.aws_event_service import ( + AwsEventServiceInjector, + ) + + env = _get_clean_env() + env['SHARED_EVENT_STORAGE_PROVIDER'] = 'AWS' + env['FILE_STORE_PATH'] = 'test-bucket' + + with patch.dict(os.environ, env, clear=True): + config = config_from_env() + assert isinstance(config.event, AwsEventServiceInjector) + + def test_provider_gcp_is_case_insensitive(self): + """Test that SHARED_EVENT_STORAGE_PROVIDER=GCP is case insensitive.""" + from openhands.app_server.config import config_from_env + from openhands.app_server.event.google_cloud_event_service import ( + GoogleCloudEventServiceInjector, + ) + + env = _get_clean_env() + env['SHARED_EVENT_STORAGE_PROVIDER'] = 'GCP' + env['FILE_STORE_PATH'] = 'test-bucket' + + with patch.dict(os.environ, env, clear=True): + config = config_from_env() + assert isinstance(config.event, GoogleCloudEventServiceInjector) diff --git a/tests/unit/utils/test_environment.py b/tests/unit/utils/test_environment.py index 5c9bdc4111..45610ff61a 100644 --- a/tests/unit/utils/test_environment.py +++ b/tests/unit/utils/test_environment.py @@ -1,6 +1,7 @@ import pytest from openhands.utils import environment +from openhands.utils.environment import StorageProvider, get_storage_provider @pytest.fixture(autouse=True) @@ -30,3 +31,48 @@ def test_get_effective_base_url_non_lemonade(monkeypatch): base_url = 'https://api.example.com' result = environment.get_effective_llm_base_url('openai/gpt-4', base_url) assert result == base_url + + +class TestGetStorageProvider: + """Tests for get_storage_provider function.""" + + def test_aws_from_shared_event_storage_provider(self, monkeypatch): + monkeypatch.setenv('SHARED_EVENT_STORAGE_PROVIDER', 'aws') + monkeypatch.delenv('FILE_STORE', raising=False) + assert get_storage_provider() == StorageProvider.AWS + + def test_gcp_from_shared_event_storage_provider(self, monkeypatch): + monkeypatch.setenv('SHARED_EVENT_STORAGE_PROVIDER', 'gcp') + monkeypatch.delenv('FILE_STORE', raising=False) + assert get_storage_provider() == StorageProvider.GCP + + def test_google_cloud_from_shared_event_storage_provider(self, monkeypatch): + monkeypatch.setenv('SHARED_EVENT_STORAGE_PROVIDER', 'google_cloud') + monkeypatch.delenv('FILE_STORE', raising=False) + assert get_storage_provider() == StorageProvider.GCP + + def test_fallback_to_file_store_google_cloud(self, monkeypatch): + monkeypatch.delenv('SHARED_EVENT_STORAGE_PROVIDER', raising=False) + monkeypatch.setenv('FILE_STORE', 'google_cloud') + assert get_storage_provider() == StorageProvider.GCP + + def test_filesystem_when_no_provider_set(self, monkeypatch): + monkeypatch.delenv('SHARED_EVENT_STORAGE_PROVIDER', raising=False) + monkeypatch.delenv('FILE_STORE', raising=False) + assert get_storage_provider() == StorageProvider.FILESYSTEM + + def test_filesystem_for_unknown_provider(self, monkeypatch): + monkeypatch.setenv('SHARED_EVENT_STORAGE_PROVIDER', 'unknown') + assert get_storage_provider() == StorageProvider.FILESYSTEM + + def test_shared_event_storage_provider_takes_precedence(self, monkeypatch): + monkeypatch.setenv('SHARED_EVENT_STORAGE_PROVIDER', 'aws') + monkeypatch.setenv('FILE_STORE', 'google_cloud') + assert get_storage_provider() == StorageProvider.AWS + + def test_case_insensitive(self, monkeypatch): + monkeypatch.setenv('SHARED_EVENT_STORAGE_PROVIDER', 'AWS') + assert get_storage_provider() == StorageProvider.AWS + + monkeypatch.setenv('SHARED_EVENT_STORAGE_PROVIDER', 'GCP') + assert get_storage_provider() == StorageProvider.GCP From f5d0af15d9aa9ffd78f9ad8dc1df652b2f2d4864 Mon Sep 17 00:00:00 2001 From: chuckbutkus Date: Fri, 13 Mar 2026 16:57:03 -0400 Subject: [PATCH 19/92] Add default initial budget for teams/users (#13389) Co-authored-by: OpenHands Bot Co-authored-by: openhands --- enterprise/storage/lite_llm_manager.py | 11 +- .../tests/unit/test_lite_llm_manager.py | 127 +++++++++++++++++- 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/enterprise/storage/lite_llm_manager.py b/enterprise/storage/lite_llm_manager.py index a7e240c7d7..836ebe8278 100644 --- a/enterprise/storage/lite_llm_manager.py +++ b/enterprise/storage/lite_llm_manager.py @@ -29,6 +29,15 @@ KEY_VERIFICATION_TIMEOUT = 5.0 # A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug. UNLIMITED_BUDGET_SETTING = 1000000000.0 +try: + DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0)) + if DEFAULT_INITIAL_BUDGET < 0: + raise ValueError( + f'DEFAULT_INITIAL_BUDGET must be non-negative, got {DEFAULT_INITIAL_BUDGET}' + ) +except ValueError as e: + raise ValueError(f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}') from e + def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str: """Generate the key alias for OpenHands Cloud managed keys.""" @@ -101,7 +110,7 @@ class LiteLlmManager: ) as client: # Check if team already exists and get its budget # New users joining existing orgs should inherit the team's budget - team_budget = 0.0 + team_budget: float = DEFAULT_INITIAL_BUDGET try: existing_team = await LiteLlmManager._get_team(client, org_id) if existing_team: diff --git a/enterprise/tests/unit/test_lite_llm_manager.py b/enterprise/tests/unit/test_lite_llm_manager.py index 8c26709aa3..1f7623d79c 100644 --- a/enterprise/tests/unit/test_lite_llm_manager.py +++ b/enterprise/tests/unit/test_lite_llm_manager.py @@ -2,7 +2,9 @@ Unit tests for LiteLlmManager class. """ +import importlib import os +import sys from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -21,6 +23,71 @@ from storage.user_settings import UserSettings from openhands.server.settings import Settings +class TestDefaultInitialBudget: + """Test cases for DEFAULT_INITIAL_BUDGET configuration.""" + + @pytest.fixture(autouse=True) + def restore_module_state(self): + """Ensure module is properly restored after each test.""" + # Save original module if it exists + original_module = sys.modules.get('storage.lite_llm_manager') + + yield + + # Restore module state after each test + if 'storage.lite_llm_manager' in sys.modules: + del sys.modules['storage.lite_llm_manager'] + + # Clear the env var + os.environ.pop('DEFAULT_INITIAL_BUDGET', None) + + # Restore original module or reimport fresh + if original_module is not None: + sys.modules['storage.lite_llm_manager'] = original_module + else: + importlib.import_module('storage.lite_llm_manager') + + def test_default_initial_budget_defaults_to_zero(self): + """Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when env var not set.""" + # Temporarily remove the module so we can reimport with different env vars + if 'storage.lite_llm_manager' in sys.modules: + del sys.modules['storage.lite_llm_manager'] + + # Clear the env var and reimport + os.environ.pop('DEFAULT_INITIAL_BUDGET', None) + module = importlib.import_module('storage.lite_llm_manager') + assert module.DEFAULT_INITIAL_BUDGET == 0.0 + + def test_default_initial_budget_uses_env_var(self): + """Test that DEFAULT_INITIAL_BUDGET uses value from environment variable.""" + if 'storage.lite_llm_manager' in sys.modules: + del sys.modules['storage.lite_llm_manager'] + + os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0' + module = importlib.import_module('storage.lite_llm_manager') + assert module.DEFAULT_INITIAL_BUDGET == 100.0 + + def test_default_initial_budget_rejects_invalid_value(self): + """Test that DEFAULT_INITIAL_BUDGET raises ValueError for invalid values.""" + if 'storage.lite_llm_manager' in sys.modules: + del sys.modules['storage.lite_llm_manager'] + + os.environ['DEFAULT_INITIAL_BUDGET'] = 'abc' + with pytest.raises(ValueError) as exc_info: + importlib.import_module('storage.lite_llm_manager') + assert 'Invalid DEFAULT_INITIAL_BUDGET' in str(exc_info.value) + + def test_default_initial_budget_rejects_negative_value(self): + """Test that DEFAULT_INITIAL_BUDGET raises ValueError for negative values.""" + if 'storage.lite_llm_manager' in sys.modules: + del sys.modules['storage.lite_llm_manager'] + + os.environ['DEFAULT_INITIAL_BUDGET'] = '-10.0' + with pytest.raises(ValueError) as exc_info: + importlib.import_module('storage.lite_llm_manager') + assert 'must be non-negative' in str(exc_info.value) + + class TestLiteLlmManager: """Test cases for LiteLlmManager class.""" @@ -242,10 +309,10 @@ class TestLiteLlmManager: assert add_user_call[1]['json']['max_budget_in_team'] == 30.0 @pytest.mark.asyncio - async def test_create_entries_new_org_uses_zero_budget( + async def test_create_entries_new_org_uses_default_initial_budget( self, mock_settings, mock_response ): - """Test that create_entries uses budget=0 for new org (team doesn't exist).""" + """Test that create_entries uses DEFAULT_INITIAL_BUDGET for new org.""" mock_404_response = MagicMock() mock_404_response.status_code = 404 mock_404_response.is_success = False @@ -273,6 +340,7 @@ class TestLiteLlmManager: patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'), patch('storage.lite_llm_manager.TokenManager', mock_token_manager), patch('httpx.AsyncClient', mock_client_class), + patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', 0.0), ): result = await LiteLlmManager.create_entries( 'test-org-id', 'test-user-id', mock_settings, create_user=False @@ -280,16 +348,67 @@ class TestLiteLlmManager: assert result is not None - # Verify _create_team was called with budget=0 + # Verify _create_team was called with DEFAULT_INITIAL_BUDGET (0.0) create_team_call = mock_client.post.call_args_list[0] assert 'team/new' in create_team_call[0][0] assert create_team_call[1]['json']['max_budget'] == 0.0 - # Verify _add_user_to_team was called with budget=0 + # Verify _add_user_to_team was called with DEFAULT_INITIAL_BUDGET (0.0) add_user_call = mock_client.post.call_args_list[1] assert 'team/member_add' in add_user_call[0][0] assert add_user_call[1]['json']['max_budget_in_team'] == 0.0 + @pytest.mark.asyncio + async def test_create_entries_new_org_uses_custom_default_budget( + self, mock_settings, mock_response + ): + """Test that create_entries uses custom DEFAULT_INITIAL_BUDGET for new org.""" + mock_404_response = MagicMock() + mock_404_response.status_code = 404 + mock_404_response.is_success = False + + mock_token_manager = MagicMock() + mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock( + return_value={'email': 'test@example.com'} + ) + + mock_client = AsyncMock() + mock_client.get.return_value = mock_404_response + mock_client.get.return_value.raise_for_status.side_effect = ( + httpx.HTTPStatusError( + message='Not Found', request=MagicMock(), response=mock_404_response + ) + ) + mock_client.post.return_value = mock_response + + mock_client_class = MagicMock() + mock_client_class.return_value.__aenter__.return_value = mock_client + + custom_budget = 50.0 + with ( + patch.dict(os.environ, {'LOCAL_DEPLOYMENT': ''}), + patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'), + patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'), + patch('storage.lite_llm_manager.TokenManager', mock_token_manager), + patch('httpx.AsyncClient', mock_client_class), + patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', custom_budget), + ): + result = await LiteLlmManager.create_entries( + 'test-org-id', 'test-user-id', mock_settings, create_user=False + ) + + assert result is not None + + # Verify _create_team was called with custom DEFAULT_INITIAL_BUDGET + create_team_call = mock_client.post.call_args_list[0] + assert 'team/new' in create_team_call[0][0] + assert create_team_call[1]['json']['max_budget'] == custom_budget + + # Verify _add_user_to_team was called with custom DEFAULT_INITIAL_BUDGET + add_user_call = mock_client.post.call_args_list[1] + assert 'team/member_add' in add_user_call[0][0] + assert add_user_call[1]['json']['max_budget_in_team'] == custom_budget + @pytest.mark.asyncio async def test_create_entries_propagates_non_404_errors(self, mock_settings): """Test that create_entries propagates non-404 errors from _get_team.""" From 873dc6628fdd965fb49539c29ab3b8a43e6f4dc7 Mon Sep 17 00:00:00 2001 From: chuckbutkus Date: Fri, 13 Mar 2026 16:57:34 -0400 Subject: [PATCH 20/92] Add Enterprise SSO login button to V1 login page (#13390) Co-authored-by: openhands --- .../features/auth/login-content.test.tsx | 69 +++++++++++++++++++ .../features/auth/login-content.tsx | 30 ++++++++ 2 files changed, 99 insertions(+) diff --git a/frontend/__tests__/components/features/auth/login-content.test.tsx b/frontend/__tests__/components/features/auth/login-content.test.tsx index efedb93164..a331ee2378 100644 --- a/frontend/__tests__/components/features/auth/login-content.test.tsx +++ b/frontend/__tests__/components/features/auth/login-content.test.tsx @@ -15,6 +15,7 @@ vi.mock("#/hooks/use-auth-url", () => ({ bitbucket: "https://bitbucket.org/site/oauth2/authorize", bitbucket_data_center: "https://bitbucket-dc.example.com/site/oauth2/authorize", + enterprise_sso: "https://auth.example.com/realms/test/protocol/openid-connect/auth", }; if (config.appMode === "saas") { return urls[config.identityProvider] || null; @@ -117,6 +118,74 @@ describe("LoginContent", () => { ).not.toBeInTheDocument(); }); + it("should display Enterprise SSO button when configured", () => { + render( + + + , + ); + + expect( + screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }), + ).toBeInTheDocument(); + }); + + it("should display Enterprise SSO alongside other providers when all configured", () => { + render( + + + , + ); + + expect( + screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /BITBUCKET\$CONNECT_TO_BITBUCKET/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }), + ).toBeInTheDocument(); + }); + + it("should redirect to Enterprise SSO auth URL when Enterprise SSO button is clicked", async () => { + const user = userEvent.setup(); + const mockUrl = "https://auth.example.com/realms/test/protocol/openid-connect/auth"; + + render( + + + , + ); + + const enterpriseSsoButton = screen.getByRole("button", { + name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i, + }); + await user.click(enterpriseSsoButton); + + await waitFor(() => { + expect(window.location.href).toContain(mockUrl); + }); + }); + it("should display message when no providers are configured", () => { render( diff --git a/frontend/src/components/features/auth/login-content.tsx b/frontend/src/components/features/auth/login-content.tsx index 30da67c301..fbae5df4ea 100644 --- a/frontend/src/components/features/auth/login-content.tsx +++ b/frontend/src/components/features/auth/login-content.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { FaUserShield } from "react-icons/fa"; import { I18nKey } from "#/i18n/declaration"; import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react"; import GitHubLogo from "#/assets/branding/github-logo.svg?react"; @@ -65,6 +66,12 @@ export function LoginContent({ authUrl, }); + const enterpriseSsoAuthUrl = useAuthUrl({ + appMode: appMode || null, + identityProvider: "enterprise_sso", + authUrl, + }); + const handleAuthRedirect = async ( redirectUrl: string, provider: Provider, @@ -127,6 +134,12 @@ export function LoginContent({ } }; + const handleEnterpriseSsoAuth = () => { + if (enterpriseSsoAuthUrl) { + handleAuthRedirect(enterpriseSsoAuthUrl, "enterprise_sso"); + } + }; + const showGithub = providersConfigured && providersConfigured.length > 0 && @@ -143,6 +156,10 @@ export function LoginContent({ providersConfigured && providersConfigured.length > 0 && providersConfigured.includes("bitbucket_data_center"); + const showEnterpriseSso = + providersConfigured && + providersConfigured.length > 0 && + providersConfigured.includes("enterprise_sso"); const noProvidersConfigured = !providersConfigured || providersConfigured.length === 0; @@ -261,6 +278,19 @@ export function LoginContent({ )} + + {showEnterpriseSso && ( + + )} )}
From b7e5c9d25bb8998ac4bfbc40bb3293cd1260617b Mon Sep 17 00:00:00 2001 From: chuckbutkus Date: Fri, 13 Mar 2026 18:39:07 -0400 Subject: [PATCH 21/92] Use a flag to indicate if new users should use V1 (#13393) Co-authored-by: openhands --- enterprise/server/constants.py | 3 + enterprise/storage/org_store.py | 3 + enterprise/storage/user_store.py | 7 ++ enterprise/tests/unit/test_org_store.py | 80 +++++++++++++++++++++++ enterprise/tests/unit/test_user_store.py | 82 ++++++++++++++++++++++++ 5 files changed, 175 insertions(+) diff --git a/enterprise/server/constants.py b/enterprise/server/constants.py index 670f28c34a..4e9734c770 100644 --- a/enterprise/server/constants.py +++ b/enterprise/server/constants.py @@ -77,6 +77,9 @@ PERMITTED_CORS_ORIGINS = [ ) ] +# Controls whether new orgs/users default to V1 API (env: DEFAULT_V1_ENABLED) +DEFAULT_V1_ENABLED = os.getenv('DEFAULT_V1_ENABLED', '1').lower() in ('1', 'true') + def build_litellm_proxy_model_path(model_name: str) -> str: """Build the LiteLLM proxy model path based on model name. diff --git a/enterprise/storage/org_store.py b/enterprise/storage/org_store.py index 3332024a1c..23015ea5be 100644 --- a/enterprise/storage/org_store.py +++ b/enterprise/storage/org_store.py @@ -6,6 +6,7 @@ from typing import Optional from uuid import UUID from server.constants import ( + DEFAULT_V1_ENABLED, LITE_LLM_API_URL, ORG_SETTINGS_VERSION, get_default_litellm_model, @@ -36,6 +37,8 @@ class OrgStore: org = Org(**kwargs) org.org_version = ORG_SETTINGS_VERSION org.default_llm_model = get_default_litellm_model() + if org.v1_enabled is None: + org.v1_enabled = DEFAULT_V1_ENABLED session.add(org) await session.commit() await session.refresh(org) diff --git a/enterprise/storage/user_store.py b/enterprise/storage/user_store.py index 9d363d10c4..ad42449285 100644 --- a/enterprise/storage/user_store.py +++ b/enterprise/storage/user_store.py @@ -7,6 +7,7 @@ from uuid import UUID from server.auth.token_manager import TokenManager from server.constants import ( + DEFAULT_V1_ENABLED, LITE_LLM_API_URL, ORG_SETTINGS_VERSION, PERSONAL_WORKSPACE_VERSION_TO_MODEL, @@ -241,6 +242,10 @@ class UserStore: if hasattr(org, key): setattr(org, key, value) + # Apply DEFAULT_V1_ENABLED for migrated orgs if v1_enabled was not set + if org.v1_enabled is None: + org.v1_enabled = DEFAULT_V1_ENABLED + user_kwargs = UserStore.get_kwargs_from_user_settings( decrypted_user_settings ) @@ -892,6 +897,8 @@ class UserStore: language='en', enable_proactive_conversation_starters=True ) + default_settings.v1_enabled = DEFAULT_V1_ENABLED + from storage.lite_llm_manager import LiteLlmManager settings = await LiteLlmManager.create_entries( diff --git a/enterprise/tests/unit/test_org_store.py b/enterprise/tests/unit/test_org_store.py index 7bada3b7b1..0c8a8173f9 100644 --- a/enterprise/tests/unit/test_org_store.py +++ b/enterprise/tests/unit/test_org_store.py @@ -144,6 +144,86 @@ async def test_create_org(async_session_maker, mock_litellm_api): assert org.id is not None +@pytest.mark.asyncio +async def test_create_org_v1_enabled_defaults_to_true_when_default_is_true( + async_session_maker, mock_litellm_api +): + """ + GIVEN: DEFAULT_V1_ENABLED is True and org.v1_enabled is not specified (None) + WHEN: create_org is called + THEN: org.v1_enabled should be set to True + """ + with ( + patch('storage.org_store.a_session_maker', async_session_maker), + patch('storage.org_store.DEFAULT_V1_ENABLED', True), + ): + org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-true'}) + + assert org is not None + assert org.v1_enabled is True + + +@pytest.mark.asyncio +async def test_create_org_v1_enabled_defaults_to_false_when_default_is_false( + async_session_maker, mock_litellm_api +): + """ + GIVEN: DEFAULT_V1_ENABLED is False and org.v1_enabled is not specified (None) + WHEN: create_org is called + THEN: org.v1_enabled should be set to False + """ + with ( + patch('storage.org_store.a_session_maker', async_session_maker), + patch('storage.org_store.DEFAULT_V1_ENABLED', False), + ): + org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-false'}) + + assert org is not None + assert org.v1_enabled is False + + +@pytest.mark.asyncio +async def test_create_org_v1_enabled_explicit_false_overrides_default_true( + async_session_maker, mock_litellm_api +): + """ + GIVEN: DEFAULT_V1_ENABLED is True but org.v1_enabled is explicitly set to False + WHEN: create_org is called + THEN: org.v1_enabled should stay False (explicit value wins over default) + """ + with ( + patch('storage.org_store.a_session_maker', async_session_maker), + patch('storage.org_store.DEFAULT_V1_ENABLED', True), + ): + org = await OrgStore.create_org( + kwargs={'name': 'test-org-v1-explicit-false', 'v1_enabled': False} + ) + + assert org is not None + assert org.v1_enabled is False + + +@pytest.mark.asyncio +async def test_create_org_v1_enabled_explicit_true_overrides_default_false( + async_session_maker, mock_litellm_api +): + """ + GIVEN: DEFAULT_V1_ENABLED is False but org.v1_enabled is explicitly set to True + WHEN: create_org is called + THEN: org.v1_enabled should stay True (explicit value wins over default) + """ + with ( + patch('storage.org_store.a_session_maker', async_session_maker), + patch('storage.org_store.DEFAULT_V1_ENABLED', False), + ): + org = await OrgStore.create_org( + kwargs={'name': 'test-org-v1-explicit-true', 'v1_enabled': True} + ) + + assert org is not None + assert org.v1_enabled is True + + @pytest.mark.asyncio async def test_get_org_by_name(async_session_maker, mock_litellm_api): # Test getting org by name diff --git a/enterprise/tests/unit/test_user_store.py b/enterprise/tests/unit/test_user_store.py index 61ea471883..e5c4262711 100644 --- a/enterprise/tests/unit/test_user_store.py +++ b/enterprise/tests/unit/test_user_store.py @@ -101,6 +101,72 @@ async def test_create_default_settings_with_litellm(mock_litellm_api): assert settings.llm_base_url == 'http://test.url' +@pytest.mark.asyncio +async def test_create_default_settings_v1_enabled_true_when_default_is_true( + mock_litellm_api, +): + """ + GIVEN: DEFAULT_V1_ENABLED is True + WHEN: create_default_settings is called + THEN: The default_settings.v1_enabled should be set to True + """ + org_id = str(uuid.uuid4()) + user_id = str(uuid.uuid4()) + + # Track the settings passed to LiteLlmManager.create_entries + captured_settings = None + + async def capture_create_entries(_org_id, _user_id, settings, _create_user): + nonlocal captured_settings + captured_settings = settings + return settings + + with ( + patch('storage.user_store.DEFAULT_V1_ENABLED', True), + patch( + 'storage.lite_llm_manager.LiteLlmManager.create_entries', + side_effect=capture_create_entries, + ), + ): + await UserStore.create_default_settings(org_id, user_id) + + assert captured_settings is not None + assert captured_settings.v1_enabled is True + + +@pytest.mark.asyncio +async def test_create_default_settings_v1_enabled_false_when_default_is_false( + mock_litellm_api, +): + """ + GIVEN: DEFAULT_V1_ENABLED is False + WHEN: create_default_settings is called + THEN: The default_settings.v1_enabled should be set to False + """ + org_id = str(uuid.uuid4()) + user_id = str(uuid.uuid4()) + + # Track the settings passed to LiteLlmManager.create_entries + captured_settings = None + + async def capture_create_entries(_org_id, _user_id, settings, _create_user): + nonlocal captured_settings + captured_settings = settings + return settings + + with ( + patch('storage.user_store.DEFAULT_V1_ENABLED', False), + patch( + 'storage.lite_llm_manager.LiteLlmManager.create_entries', + side_effect=capture_create_entries, + ), + ): + await UserStore.create_default_settings(org_id, user_id) + + assert captured_settings is not None + assert captured_settings.v1_enabled is False + + # --- Tests for get_user_by_id --- @@ -1243,3 +1309,19 @@ async def test_migrate_user_sql_multiple_conversations(async_session_maker): assert ( row.org_id == user_uuid_str ), f'org_id should match: {row.org_id} vs {user_uuid_str}' + + +# Note: The v1_enabled logic in migrate_user follows the same pattern as OrgStore.create_org: +# if org.v1_enabled is None: +# org.v1_enabled = DEFAULT_V1_ENABLED +# +# This behavior is tested in test_org_store.py via: +# - test_create_org_v1_enabled_defaults_to_true_when_default_is_true +# - test_create_org_v1_enabled_defaults_to_false_when_default_is_false +# - test_create_org_v1_enabled_explicit_false_overrides_default_true +# - test_create_org_v1_enabled_explicit_true_overrides_default_false +# +# Testing migrate_user directly is impractical due to its complex raw SQL migration +# statements that have SQLite/UUID compatibility issues in the test environment. +# The SQL migration tests above (test_migrate_user_sql_type_handling, etc.) verify +# the SQL operations work correctly with proper type handling. From 8189d21445c27e260aeb456e5d7e3ba06f0d5da0 Mon Sep 17 00:00:00 2001 From: chuckbutkus Date: Fri, 13 Mar 2026 19:13:18 -0400 Subject: [PATCH 22/92] Fix async call to await return (#13395) --- .../app_conversation/app_conversation_service_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands/app_server/app_conversation/app_conversation_service_base.py b/openhands/app_server/app_conversation/app_conversation_service_base.py index 52cdf660d1..0d66e16a4f 100644 --- a/openhands/app_server/app_conversation/app_conversation_service_base.py +++ b/openhands/app_server/app_conversation/app_conversation_service_base.py @@ -404,7 +404,7 @@ class AppConversationServiceBase(AppConversationService, ABC): # Check if there's an existing pre-commit hook with tempfile.TemporaryFile(mode='w+t') as temp_file: - result = workspace.file_download(PRE_COMMIT_HOOK, str(temp_file)) + result = await workspace.file_download(PRE_COMMIT_HOOK, str(temp_file)) if result.get('success'): _logger.info('Preserving existing pre-commit hook') # an existing pre-commit hook exists From 0c51089ab6fba2f98c33a969c0b40ec88134e6e9 Mon Sep 17 00:00:00 2001 From: "John-Mason P. Shackelford" Date: Fri, 13 Mar 2026 21:07:20 -0400 Subject: [PATCH 23/92] Upgrade the SDK to 1.14.0 (#13398) Co-authored-by: openhands --- enterprise/poetry.lock | 190 ++++++++---------- .../sandbox/sandbox_spec_service.py | 2 +- poetry.lock | 20 +- pyproject.toml | 12 +- uv.lock | 24 +-- 5 files changed, 118 insertions(+), 130 deletions(-) diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index ddf6d37bcc..b1cca69bc5 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -6190,14 +6190,14 @@ 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.13.0" +version = "1.14.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"}, - {file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"}, + {file = "openhands_agent_server-1.14.0-py3-none-any.whl", hash = "sha256:b1374b50d0ce93d825ba5ea907fcb8840b5ddc594c6752570c7c4c27be1a9fd1"}, + {file = "openhands_agent_server-1.14.0.tar.gz", hash = "sha256:396de8d878c0a6c1c23d830f7407e34801ac850f4283ba296d7fe436d8b61488"}, ] [package.dependencies] @@ -6259,11 +6259,12 @@ memory-profiler = ">=0.61" numpy = "*" openai = "2.8" openhands-aci = "0.3.3" -openhands-agent-server = "1.13" -openhands-sdk = "1.13" -openhands-tools = "1.13" +openhands-agent-server = "1.14" +openhands-sdk = "1.14" +openhands-tools = "1.14" opentelemetry-api = ">=1.33.1" opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1" +orjson = ">=3.11.6" pathspec = ">=0.12.1" pexpect = "*" pg8000 = ">=1.31.5" @@ -6315,14 +6316,14 @@ url = ".." [[package]] name = "openhands-sdk" -version = "1.13.0" +version = "1.14.0" description = "OpenHands SDK - Core functionality for building AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"}, - {file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"}, + {file = "openhands_sdk-1.14.0-py3-none-any.whl", hash = "sha256:64305b3a24445fd9480b63129e8e02f3a75fdbf8f4fcbf970760b7dc1d392090"}, + {file = "openhands_sdk-1.14.0.tar.gz", hash = "sha256:30bda4b10291420f753d14aaa4ee67c87ba8d59ef3908bca999aa76daa033615"}, ] [package.dependencies] @@ -6345,14 +6346,14 @@ boto3 = ["boto3 (>=1.35.0)"] [[package]] name = "openhands-tools" -version = "1.13.0" +version = "1.14.0" description = "OpenHands Tools - Runtime tools for AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"}, - {file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"}, + {file = "openhands_tools-1.14.0-py3-none-any.whl", hash = "sha256:4df477fa53eafa15082d081143c80383aeb6d52b4448b989b86b811c297e5615"}, + {file = "openhands_tools-1.14.0.tar.gz", hash = "sha256:2655a7de839b171539464fa39729b6a338dc37f914b58bd551378c4fc0ec71b5"}, ] [package.dependencies] @@ -6560,99 +6561,86 @@ files = [ [[package]] name = "orjson" -version = "3.11.5" +version = "3.11.7" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e"}, - {file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7"}, - {file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401"}, - {file = "orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8"}, - {file = "orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167"}, - {file = "orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8"}, - {file = "orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef"}, - {file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5"}, - {file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880"}, - {file = "orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d"}, - {file = "orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1"}, - {file = "orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c"}, - {file = "orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d"}, - {file = "orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa"}, - {file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3"}, - {file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca"}, - {file = "orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98"}, - {file = "orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875"}, - {file = "orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe"}, - {file = "orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629"}, - {file = "orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706"}, - {file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2"}, - {file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05"}, - {file = "orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef"}, - {file = "orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583"}, - {file = "orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287"}, - {file = "orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0"}, - {file = "orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4"}, - {file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d"}, - {file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439"}, - {file = "orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499"}, - {file = "orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310"}, - {file = "orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5"}, - {file = "orjson-3.11.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1b280e2d2d284a6713b0cfec7b08918ebe57df23e3f76b27586197afca3cb1e9"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d8a112b274fae8c5f0f01954cb0480137072c271f3f4958127b010dfefaec"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0a2ae6f09ac7bd47d2d5a5305c1d9ed08ac057cda55bb0a49fa506f0d2da00"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0d87bd1896faac0d10b4f849016db81a63e4ec5df38757ffae84d45ab38aa71"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:801a821e8e6099b8c459ac7540b3c32dba6013437c57fdcaec205b169754f38c"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a0f6ac618c98c74b7fbc8c0172ba86f9e01dbf9f62aa0b1776c2231a7bffe5"}, - {file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea7339bdd22e6f1060c55ac31b6a755d86a5b2ad3657f2669ec243f8e3b2bdb"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4dad582bc93cef8f26513e12771e76385a7e6187fd713157e971c784112aad56"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0522003e9f7fba91982e83a97fec0708f5a714c96c4209db7104e6b9d132f111"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7403851e430a478440ecc1258bcbacbfbd8175f9ac1e39031a7121dd0de05ff8"}, - {file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5f691263425d3177977c8d1dd896cde7b98d93cbf390b2544a090675e83a6a0a"}, - {file = "orjson-3.11.5-cp39-cp39-win32.whl", hash = "sha256:61026196a1c4b968e1b1e540563e277843082e9e97d78afa03eb89315af531f1"}, - {file = "orjson-3.11.5-cp39-cp39-win_amd64.whl", hash = "sha256:09b94b947ac08586af635ef922d69dc9bc63321527a3a04647f4986a73f4bd30"}, - {file = "orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5"}, + {file = "orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222"}, + {file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa"}, + {file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e"}, + {file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2"}, + {file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c"}, + {file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f"}, + {file = "orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de"}, + {file = "orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993"}, + {file = "orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c"}, + {file = "orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b"}, + {file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960"}, + {file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8"}, + {file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504"}, + {file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e"}, + {file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561"}, + {file = "orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d"}, + {file = "orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471"}, + {file = "orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d"}, + {file = "orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f"}, + {file = "orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f"}, + {file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad"}, + {file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867"}, + {file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d"}, + {file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab"}, + {file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2"}, + {file = "orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f"}, + {file = "orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74"}, + {file = "orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5"}, + {file = "orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733"}, + {file = "orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705"}, + {file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace"}, + {file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b"}, + {file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157"}, + {file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3"}, + {file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223"}, + {file = "orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3"}, + {file = "orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757"}, + {file = "orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539"}, + {file = "orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0"}, + {file = "orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e"}, + {file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1"}, + {file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183"}, + {file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650"}, + {file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141"}, + {file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2"}, + {file = "orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576"}, + {file = "orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1"}, + {file = "orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d"}, + {file = "orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49"}, ] [[package]] diff --git a/openhands/app_server/sandbox/sandbox_spec_service.py b/openhands/app_server/sandbox/sandbox_spec_service.py index 520b5e0322..4034af1f5b 100644 --- a/openhands/app_server/sandbox/sandbox_spec_service.py +++ b/openhands/app_server/sandbox/sandbox_spec_service.py @@ -13,7 +13,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin # The version of the agent server to use for deployments. # Typically this will be the same as the values from the pyproject.toml -AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.13.0-python' +AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.14.0-python' class SandboxSpecService(ABC): diff --git a/poetry.lock b/poetry.lock index 8b53c040e9..6581b648dd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6367,14 +6367,14 @@ 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.13.0" +version = "1.14.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"}, - {file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"}, + {file = "openhands_agent_server-1.14.0-py3-none-any.whl", hash = "sha256:b1374b50d0ce93d825ba5ea907fcb8840b5ddc594c6752570c7c4c27be1a9fd1"}, + {file = "openhands_agent_server-1.14.0.tar.gz", hash = "sha256:396de8d878c0a6c1c23d830f7407e34801ac850f4283ba296d7fe436d8b61488"}, ] [package.dependencies] @@ -6391,14 +6391,14 @@ wsproto = ">=1.2.0" [[package]] name = "openhands-sdk" -version = "1.13.0" +version = "1.14.0" description = "OpenHands SDK - Core functionality for building AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"}, - {file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"}, + {file = "openhands_sdk-1.14.0-py3-none-any.whl", hash = "sha256:64305b3a24445fd9480b63129e8e02f3a75fdbf8f4fcbf970760b7dc1d392090"}, + {file = "openhands_sdk-1.14.0.tar.gz", hash = "sha256:30bda4b10291420f753d14aaa4ee67c87ba8d59ef3908bca999aa76daa033615"}, ] [package.dependencies] @@ -6421,14 +6421,14 @@ boto3 = ["boto3 (>=1.35.0)"] [[package]] name = "openhands-tools" -version = "1.13.0" +version = "1.14.0" description = "OpenHands Tools - Runtime tools for AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"}, - {file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"}, + {file = "openhands_tools-1.14.0-py3-none-any.whl", hash = "sha256:4df477fa53eafa15082d081143c80383aeb6d52b4448b989b86b811c297e5615"}, + {file = "openhands_tools-1.14.0.tar.gz", hash = "sha256:2655a7de839b171539464fa39729b6a338dc37f914b58bd551378c4fc0ec71b5"}, ] [package.dependencies] @@ -14833,4 +14833,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api [metadata] lock-version = "2.1" python-versions = "^3.12,<3.14" -content-hash = "a2b101c19697e385c325c88f5ab454a5c43133469fc01eef856010da1fa420d3" +content-hash = "3eeaae126c61083ae45387053d27c17072ffbaa4ce94e9e7cea32df79a06046a" diff --git a/pyproject.toml b/pyproject.toml index fc8b21e368..b1bfd86514 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,9 +57,9 @@ dependencies = [ "numpy", "openai==2.8", "openhands-aci==0.3.3", - "openhands-agent-server==1.13", - "openhands-sdk==1.13", - "openhands-tools==1.13", + "openhands-agent-server==1.14", + "openhands-sdk==1.14", + "openhands-tools==1.14", "opentelemetry-api>=1.33.1", "opentelemetry-exporter-otlp-proto-grpc>=1.33.1", "orjson>=3.11.6", @@ -251,9 +251,9 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true } pybase62 = "^1.0.0" # V1 dependencies -openhands-sdk = "1.13" -openhands-agent-server = "1.13" -openhands-tools = "1.13" +openhands-sdk = "1.14" +openhands-agent-server = "1.14" +openhands-tools = "1.14" jwcrypto = ">=1.5.6" sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" } pg8000 = "^1.31.5" diff --git a/uv.lock b/uv.lock index 11f13182df..7053c0d6c2 100644 --- a/uv.lock +++ b/uv.lock @@ -3642,7 +3642,7 @@ wheels = [ [[package]] name = "openhands-agent-server" -version = "1.13.0" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -3656,9 +3656,9 @@ dependencies = [ { name = "websockets" }, { name = "wsproto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/d0/419756ad3368e7ab47c07111dfb4bf40073c110817914e09553b8e056fe8/openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a", size = 73594, upload-time = "2026-03-10T18:41:25.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/9f/66c45457f7510876bdf337feeef53c1fc6a2521be5ec707b63bcc76810d7/openhands_agent_server-1.14.0.tar.gz", hash = "sha256:396de8d878c0a6c1c23d830f7407e34801ac850f4283ba296d7fe436d8b61488", size = 75545, upload-time = "2026-03-13T21:19:09.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/e1/77b9b3181e6cba89c601533757d148f911416ff968a4ea5fe0882d479ccf/openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29", size = 88607, upload-time = "2026-03-10T18:41:18.321Z" }, + { url = "https://files.pythonhosted.org/packages/0c/df/55a79fa605b2dcf6cc524525cefa5d04cf0083649a77f4130c6b14e9b153/openhands_agent_server-1.14.0-py3-none-any.whl", hash = "sha256:b1374b50d0ce93d825ba5ea907fcb8840b5ddc594c6752570c7c4c27be1a9fd1", size = 90634, upload-time = "2026-03-13T21:19:15.859Z" }, ] [[package]] @@ -3827,9 +3827,9 @@ requires-dist = [ { name = "numpy" }, { name = "openai", specifier = "==2.8" }, { name = "openhands-aci", specifier = "==0.3.3" }, - { name = "openhands-agent-server", specifier = "==1.13" }, - { name = "openhands-sdk", specifier = "==1.13" }, - { name = "openhands-tools", specifier = "==1.13" }, + { name = "openhands-agent-server", specifier = "==1.14" }, + { name = "openhands-sdk", specifier = "==1.14" }, + { name = "openhands-tools", specifier = "==1.14" }, { name = "opentelemetry-api", specifier = ">=1.33.1" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.33.1" }, { name = "orjson", specifier = ">=3.11.6" }, @@ -3908,7 +3908,7 @@ test = [ [[package]] name = "openhands-sdk" -version = "1.13.0" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "agent-client-protocol" }, @@ -3925,14 +3925,14 @@ dependencies = [ { name = "tenacity" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/d0/5e35e99252f16c3e9b8eec843b7054ed7d3ad9fadcc0b40064ab3de55469/openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c", size = 330526, upload-time = "2026-03-10T18:41:19.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/59/52aa47a54243132d57e29920ce38fbc08fee71532d4ea91647916c441859/openhands_sdk-1.14.0.tar.gz", hash = "sha256:30bda4b10291420f753d14aaa4ee67c87ba8d59ef3908bca999aa76daa033615", size = 332289, upload-time = "2026-03-13T21:19:13.81Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b1/31737964179a8e5a0ed1d0485082a703e2d4cd346701ab4a383ddf33eebb/openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185", size = 420504, upload-time = "2026-03-10T18:41:24.224Z" }, + { url = "https://files.pythonhosted.org/packages/d8/27/bab7af3fb67bdfa1f7278c23713443775940800687b83dd33a9e46f8653a/openhands_sdk-1.14.0-py3-none-any.whl", hash = "sha256:64305b3a24445fd9480b63129e8e02f3a75fdbf8f4fcbf970760b7dc1d392090", size = 422447, upload-time = "2026-03-13T21:19:10.414Z" }, ] [[package]] name = "openhands-tools" -version = "1.13.0" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bashlex" }, @@ -3945,9 +3945,9 @@ dependencies = [ { name = "pydantic" }, { name = "tom-swe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/91/0af0f29dc0da57e7df13bd1653eff80d5c47b8311c6825568837d6ba2af7/openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d", size = 111922, upload-time = "2026-03-10T18:41:26.872Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/55/5201e34dae494bb30763ab920728583c14dff80a54bcf0e279ae46ab32ad/openhands_tools-1.14.0.tar.gz", hash = "sha256:2655a7de839b171539464fa39729b6a338dc37f914b58bd551378c4fc0ec71b5", size = 112223, upload-time = "2026-03-13T21:19:15.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/e7/44d677fdd73f249c9bc8a76d2a32848ed96f54324b7d4b0589bb70f7d4e8/openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68", size = 152193, upload-time = "2026-03-10T18:41:20.563Z" }, + { url = "https://files.pythonhosted.org/packages/e7/37/6872132cb83cd80c7bf85deb6ff8a3c9e0e37ae89e0724d1119209d82601/openhands_tools-1.14.0-py3-none-any.whl", hash = "sha256:4df477fa53eafa15082d081143c80383aeb6d52b4448b989b86b811c297e5615", size = 152507, upload-time = "2026-03-13T21:19:11.855Z" }, ] [[package]] From a14158e8185ce89ea0ba5321be36f22104dc8189 Mon Sep 17 00:00:00 2001 From: chuckbutkus Date: Fri, 13 Mar 2026 21:08:23 -0400 Subject: [PATCH 24/92] fix: use query params for file upload path (#13376) Co-authored-by: openhands --- .../api/v1-conversation-service.test.ts | 92 ++++++++++++++++++- .../v1-conversation-service.api.ts | 7 +- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/frontend/__tests__/api/v1-conversation-service.test.ts b/frontend/__tests__/api/v1-conversation-service.test.ts index 99039967f1..09b843244a 100644 --- a/frontend/__tests__/api/v1-conversation-service.test.ts +++ b/frontend/__tests__/api/v1-conversation-service.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach, Mock } from "vitest"; +import axios from "axios"; import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() })); @@ -6,6 +7,8 @@ vi.mock("#/api/open-hands-axios", () => ({ openHands: { get: mockGet }, })); +vi.mock("axios"); + describe("V1ConversationService", () => { describe("readConversationFile", () => { it("uses default plan path when filePath is not provided", async () => { @@ -24,4 +27,91 @@ describe("V1ConversationService", () => { ); }); }); + + describe("uploadFile", () => { + beforeEach(() => { + vi.clearAllMocks(); + (axios.post as Mock).mockResolvedValue({ data: {} }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("uses query params for file upload path", async () => { + // Arrange + const conversationUrl = "http://localhost:54928/api/conversations/conv-123"; + const sessionApiKey = "test-api-key"; + const file = new File(["test content"], "test.txt", { type: "text/plain" }); + const uploadPath = "/workspace/custom/path.txt"; + + // Act + await V1ConversationService.uploadFile( + conversationUrl, + sessionApiKey, + file, + uploadPath, + ); + + // Assert + expect(axios.post).toHaveBeenCalledTimes(1); + const callUrl = (axios.post as Mock).mock.calls[0][0] as string; + + // Verify URL uses query params format + expect(callUrl).toContain("/api/file/upload?"); + expect(callUrl).toContain("path=%2Fworkspace%2Fcustom%2Fpath.txt"); + + // Verify it's NOT using path params format + expect(callUrl).not.toContain("/api/file/upload/%2F"); + }); + + it("uses default workspace path when no path provided", async () => { + // Arrange + const conversationUrl = "http://localhost:54928/api/conversations/conv-123"; + const sessionApiKey = "test-api-key"; + const file = new File(["test content"], "myfile.txt", { type: "text/plain" }); + + // Act + await V1ConversationService.uploadFile( + conversationUrl, + sessionApiKey, + file, + ); + + // Assert + expect(axios.post).toHaveBeenCalledTimes(1); + const callUrl = (axios.post as Mock).mock.calls[0][0] as string; + + // Default path should be /workspace/{filename} + expect(callUrl).toContain("path=%2Fworkspace%2Fmyfile.txt"); + }); + + it("sends file as FormData with correct headers", async () => { + // Arrange + const conversationUrl = "http://localhost:54928/api/conversations/conv-123"; + const sessionApiKey = "test-api-key"; + const file = new File(["test content"], "test.txt", { type: "text/plain" }); + + // Act + await V1ConversationService.uploadFile( + conversationUrl, + sessionApiKey, + file, + ); + + // Assert + expect(axios.post).toHaveBeenCalledTimes(1); + const callArgs = (axios.post as Mock).mock.calls[0]; + + // Verify FormData is sent + const formData = callArgs[1]; + expect(formData).toBeInstanceOf(FormData); + expect(formData.get("file")).toBe(file); + + // Verify headers include session API key and content type + const headers = callArgs[2].headers; + expect(headers).toHaveProperty("X-Session-API-Key", sessionApiKey); + expect(headers).toHaveProperty("Content-Type", "multipart/form-data"); + }); + }); }); diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index 56942b54c4..17cbb24cdf 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -253,7 +253,7 @@ class V1ConversationService { /** * Upload a single file to the V1 conversation workspace - * V1 API endpoint: POST /api/file/upload/{path} + * V1 API endpoint: POST /api/file/upload?path={path} * * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") * @param sessionApiKey Session API key for authentication (required for V1) @@ -269,10 +269,11 @@ class V1ConversationService { ): Promise { // Default to /workspace/{filename} if no path provided (must be absolute) const uploadPath = path || `/workspace/${file.name}`; - const encodedPath = encodeURIComponent(uploadPath); + const params = new URLSearchParams(); + params.append("path", uploadPath); const url = this.buildRuntimeUrl( conversationUrl, - `/api/file/upload/${encodedPath}`, + `/api/file/upload?${params.toString()}`, ); const headers = buildSessionHeaders(sessionApiKey); From a8ff720b40791d91719a203dcb3084b6579e28fd Mon Sep 17 00:00:00 2001 From: Ray Myers Date: Fri, 13 Mar 2026 22:48:50 -0500 Subject: [PATCH 25/92] chore: Update imagemagick in Debian images for security patches (#13397) --- .../runtime/utils/runtime_templates/Dockerfile.j2 | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/openhands/runtime/utils/runtime_templates/Dockerfile.j2 b/openhands/runtime/utils/runtime_templates/Dockerfile.j2 index 2eeafbdf71..a02229995f 100644 --- a/openhands/runtime/utils/runtime_templates/Dockerfile.j2 +++ b/openhands/runtime/utils/runtime_templates/Dockerfile.j2 @@ -45,7 +45,15 @@ RUN apt-get update && \ libasound2-plugins libatomic1 && \ (apt-get install -y --no-install-recommends libgl1 || apt-get install -y --no-install-recommends libgl1-mesa-glx) && \ # Install Docker dependencies - apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release + apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release && \ + # Security upgrade: patch ImageMagick CVEs (CVE-2026-25897, CVE-2026-25968, CVE-2026-26284, et al.) + (apt-get install -y --no-install-recommends --only-upgrade \ + imagemagick imagemagick-7-common imagemagick-7.q16 \ + libmagickcore-7-arch-config libmagickcore-7-headers \ + libmagickcore-7.q16-10 libmagickcore-7.q16-10-extra \ + libmagickcore-7.q16-dev libmagickcore-dev \ + libmagickwand-7-headers libmagickwand-7.q16-10 \ + libmagickwand-7.q16-dev libmagickwand-dev || true) {% endif %} {% if (('ubuntu' in base_image) or ('mswebench' in base_image)) %} From c66a112bf5448a31cf8ae74d389e61142cdd66c6 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:56:57 +0700 Subject: [PATCH 26/92] fix(frontend): add rendering support for GlobObservation and GrepObservation events (#13379) --- .../get-observation-content.test.ts | 215 +++++++++++++++++- .../get-event-content.tsx | 16 ++ .../get-observation-content.ts | 78 +++++++ frontend/src/i18n/declaration.ts | 2 + frontend/src/i18n/translation.json | 32 +++ frontend/src/types/v1/core/base/base.ts | 4 +- .../src/types/v1/core/base/observation.ts | 62 ++++- 7 files changed, 406 insertions(+), 3 deletions(-) diff --git a/frontend/__tests__/components/v1/chat/event-content-helpers/get-observation-content.test.ts b/frontend/__tests__/components/v1/chat/event-content-helpers/get-observation-content.test.ts index 9e2da14a26..cebdbdab0c 100644 --- a/frontend/__tests__/components/v1/chat/event-content-helpers/get-observation-content.test.ts +++ b/frontend/__tests__/components/v1/chat/event-content-helpers/get-observation-content.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect } from "vitest"; import { getObservationContent } from "#/components/v1/chat/event-content-helpers/get-observation-content"; import { ObservationEvent } from "#/types/v1/core"; -import { BrowserObservation } from "#/types/v1/core/base/observation"; +import { + BrowserObservation, + GlobObservation, + GrepObservation, +} from "#/types/v1/core/base/observation"; describe("getObservationContent - BrowserObservation", () => { it("should return output content when available", () => { @@ -90,3 +94,212 @@ describe("getObservationContent - BrowserObservation", () => { expect(result).toBe("**Output:**\nPage loaded successfully"); }); }); + +describe("getObservationContent - GlobObservation", () => { + it("should display files found when glob matches files", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "glob", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GlobObservation", + content: [{ type: "text", text: "Found 2 files", cache_prompt: false }], + is_error: false, + files: ["/workspace/src/index.ts", "/workspace/src/app.ts"], + pattern: "**/*.ts", + search_path: "/workspace", + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Pattern:** `**/*.ts`"); + expect(result).toContain("**Search Path:** `/workspace`"); + expect(result).toContain("**Files Found (2):**"); + expect(result).toContain("- `/workspace/src/index.ts`"); + expect(result).toContain("- `/workspace/src/app.ts`"); + }); + + it("should display no files found message when glob matches nothing", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "glob", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GlobObservation", + content: [{ type: "text", text: "No files found", cache_prompt: false }], + is_error: false, + files: [], + pattern: "**/*.xyz", + search_path: "/workspace", + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Pattern:** `**/*.xyz`"); + expect(result).toContain("**Result:** No files found."); + }); + + it("should display error when glob operation fails", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "glob", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GlobObservation", + content: [{ type: "text", text: "Permission denied", cache_prompt: false }], + is_error: true, + files: [], + pattern: "**/*", + search_path: "/restricted", + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Error:**"); + expect(result).toContain("Permission denied"); + }); + + it("should indicate truncation when results exceed limit", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "glob", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GlobObservation", + content: [{ type: "text", text: "Found files", cache_prompt: false }], + is_error: false, + files: ["/workspace/file1.ts"], + pattern: "**/*.ts", + search_path: "/workspace", + truncated: true, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Files Found (1+, truncated):**"); + }); +}); + +describe("getObservationContent - GrepObservation", () => { + it("should display matches found when grep finds results", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "grep", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GrepObservation", + content: [{ type: "text", text: "Found 2 matches", cache_prompt: false }], + is_error: false, + matches: ["/workspace/src/api.ts", "/workspace/src/routes.ts"], + pattern: "fetchData", + search_path: "/workspace", + include_pattern: "*.ts", + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Pattern:** `fetchData`"); + expect(result).toContain("**Search Path:** `/workspace`"); + expect(result).toContain("**Include:** `*.ts`"); + expect(result).toContain("**Matches (2):**"); + expect(result).toContain("- `/workspace/src/api.ts`"); + }); + + it("should display no matches found when grep finds nothing", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "grep", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GrepObservation", + content: [{ type: "text", text: "No matches", cache_prompt: false }], + is_error: false, + matches: [], + pattern: "nonExistentFunction", + search_path: "/workspace", + include_pattern: null, + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Pattern:** `nonExistentFunction`"); + expect(result).toContain("**Result:** No matches found."); + expect(result).not.toContain("**Include:**"); + }); + + it("should display error when grep operation fails", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "grep", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GrepObservation", + content: [{ type: "text", text: "Invalid regex pattern", cache_prompt: false }], + is_error: true, + matches: [], + pattern: "[invalid", + search_path: "/workspace", + include_pattern: null, + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Error:**"); + expect(result).toContain("Invalid regex pattern"); + }); +}); diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx index dec57f385f..88f546d5b1 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx +++ b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx @@ -162,6 +162,22 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => { case "ThinkObservation": observationKey = "OBSERVATION_MESSAGE$THINK"; break; + case "GlobObservation": + observationKey = "OBSERVATION_MESSAGE$GLOB"; + observationValues = { + pattern: event.observation.pattern + ? trimText(event.observation.pattern, 50) + : "", + }; + break; + case "GrepObservation": + observationKey = "OBSERVATION_MESSAGE$GREP"; + observationValues = { + pattern: event.observation.pattern + ? trimText(event.observation.pattern, 50) + : "", + }; + break; default: // For unknown observations, use the type name return observationType.replace("Observation", "").toUpperCase(); diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts index 7fb1c2ce1c..082cef7d74 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts @@ -12,6 +12,8 @@ import { FileEditorObservation, StrReplaceEditorObservation, TaskTrackerObservation, + GlobObservation, + GrepObservation, } from "#/types/v1/core/base/observation"; // File Editor Observations @@ -221,6 +223,72 @@ const getFinishObservationContent = ( return content; }; +// Glob Observations +const getGlobObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + // Extract text content from the observation + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + let content = `**Pattern:** \`${observation.pattern}\`\n`; + content += `**Search Path:** \`${observation.search_path}\`\n\n`; + + if (observation.is_error) { + content += `**Error:**\n${textContent}`; + } else if (observation.files.length === 0) { + content += "**Result:** No files found."; + } else { + content += `**Files Found (${observation.files.length}${observation.truncated ? "+, truncated" : ""}):**\n`; + content += observation.files.map((f) => `- \`${f}\``).join("\n"); + } + + if (content.length > MAX_CONTENT_LENGTH) { + content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; + } + + return content; +}; + +// Grep Observations +const getGrepObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + // Extract text content from the observation + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + let content = `**Pattern:** \`${observation.pattern}\`\n`; + content += `**Search Path:** \`${observation.search_path}\`\n`; + if (observation.include_pattern) { + content += `**Include:** \`${observation.include_pattern}\`\n`; + } + content += "\n"; + + if (observation.is_error) { + content += `**Error:**\n${textContent}`; + } else if (observation.matches.length === 0) { + content += "**Result:** No matches found."; + } else { + content += `**Matches (${observation.matches.length}${observation.truncated ? "+, truncated" : ""}):**\n`; + content += observation.matches.map((f) => `- \`${f}\``).join("\n"); + } + + if (content.length > MAX_CONTENT_LENGTH) { + content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; + } + + return content; +}; + export const getObservationContent = (event: ObservationEvent): string => { const observationType = event.observation.kind; @@ -264,6 +332,16 @@ export const getObservationContent = (event: ObservationEvent): string => { event as ObservationEvent, ); + case "GlobObservation": + return getGlobObservationContent( + event as ObservationEvent, + ); + + case "GrepObservation": + return getGrepObservationContent( + event as ObservationEvent, + ); + default: return getDefaultEventContent(event); } diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index aac6e1b0f6..10e9d885fd 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -536,6 +536,8 @@ export enum I18nKey { OBSERVATION_MESSAGE$MCP = "OBSERVATION_MESSAGE$MCP", OBSERVATION_MESSAGE$RECALL = "OBSERVATION_MESSAGE$RECALL", OBSERVATION_MESSAGE$THINK = "OBSERVATION_MESSAGE$THINK", + OBSERVATION_MESSAGE$GLOB = "OBSERVATION_MESSAGE$GLOB", + OBSERVATION_MESSAGE$GREP = "OBSERVATION_MESSAGE$GREP", OBSERVATION_MESSAGE$TASK_TRACKING_PLAN = "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN", OBSERVATION_MESSAGE$TASK_TRACKING_VIEW = "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW", EXPANDABLE_MESSAGE$SHOW_DETAILS = "EXPANDABLE_MESSAGE$SHOW_DETAILS", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 129784a357..abeba30110 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -8575,6 +8575,38 @@ "de": "Gedanke", "uk": "Думка" }, + "OBSERVATION_MESSAGE$GLOB": { + "en": "Glob: {{pattern}}", + "ja": "Glob: {{pattern}}", + "zh-CN": "Glob: {{pattern}}", + "zh-TW": "Glob: {{pattern}}", + "ko-KR": "Glob: {{pattern}}", + "no": "Glob: {{pattern}}", + "it": "Glob: {{pattern}}", + "pt": "Glob: {{pattern}}", + "es": "Glob: {{pattern}}", + "ar": "Glob: {{pattern}}", + "fr": "Glob: {{pattern}}", + "tr": "Glob: {{pattern}}", + "de": "Glob: {{pattern}}", + "uk": "Glob: {{pattern}}" + }, + "OBSERVATION_MESSAGE$GREP": { + "en": "Grep: {{pattern}}", + "ja": "Grep: {{pattern}}", + "zh-CN": "Grep: {{pattern}}", + "zh-TW": "Grep: {{pattern}}", + "ko-KR": "Grep: {{pattern}}", + "no": "Grep: {{pattern}}", + "it": "Grep: {{pattern}}", + "pt": "Grep: {{pattern}}", + "es": "Grep: {{pattern}}", + "ar": "Grep: {{pattern}}", + "fr": "Grep: {{pattern}}", + "tr": "Grep: {{pattern}}", + "de": "Grep: {{pattern}}", + "uk": "Grep: {{pattern}}" + }, "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN": { "en": "Agent updated the plan", "zh-CN": "代理更新了计划", diff --git a/frontend/src/types/v1/core/base/base.ts b/frontend/src/types/v1/core/base/base.ts index 7704f1105d..531168d17b 100644 --- a/frontend/src/types/v1/core/base/base.ts +++ b/frontend/src/types/v1/core/base/base.ts @@ -27,7 +27,9 @@ type ActionEventType = `${ActionOnlyType}Action` | `${EventType}Action`; type ObservationEventType = | `${ObservationOnlyType}Observation` | `${EventType}Observation` - | "TerminalObservation"; + | "TerminalObservation" + | "GlobObservation" + | "GrepObservation"; export interface ActionBase { kind: T; diff --git a/frontend/src/types/v1/core/base/observation.ts b/frontend/src/types/v1/core/base/observation.ts index a1c8a1a48d..ac2a90fdf2 100644 --- a/frontend/src/types/v1/core/base/observation.ts +++ b/frontend/src/types/v1/core/base/observation.ts @@ -217,6 +217,64 @@ export interface PlanningFileEditorObservation extends ObservationBase<"Planning new_content: string | null; } +export interface GlobObservation extends ObservationBase<"GlobObservation"> { + /** + * Content returned from the tool as a list of TextContent/ImageContent objects. + */ + content: Array; + /** + * Whether the call resulted in an error. + */ + is_error: boolean; + /** + * List of matching file paths sorted by modification time. + */ + files: string[]; + /** + * The glob pattern that was used. + */ + pattern: string; + /** + * The directory that was searched. + */ + search_path: string; + /** + * Whether results were truncated to 100 files. + */ + truncated: boolean; +} + +export interface GrepObservation extends ObservationBase<"GrepObservation"> { + /** + * Content returned from the tool as a list of TextContent/ImageContent objects. + */ + content: Array; + /** + * Whether the call resulted in an error. + */ + is_error: boolean; + /** + * List of file paths containing the pattern. + */ + matches: string[]; + /** + * The regex pattern that was used. + */ + pattern: string; + /** + * The directory that was searched. + */ + search_path: string; + /** + * The file pattern filter that was used. + */ + include_pattern: string | null; + /** + * Whether results were truncated to 100 files. + */ + truncated: boolean; +} + export type Observation = | MCPToolObservation | FinishObservation @@ -227,4 +285,6 @@ export type Observation = | FileEditorObservation | StrReplaceEditorObservation | TaskTrackerObservation - | PlanningFileEditorObservation; + | PlanningFileEditorObservation + | GlobObservation + | GrepObservation; From f7ca32126f21f54dd7086c0aeac3992abeb516ae Mon Sep 17 00:00:00 2001 From: aivong-openhands Date: Sat, 14 Mar 2026 09:35:56 -0500 Subject: [PATCH 27/92] Fix CVE-2026-32597: Update pyjwt to 2.12.0 (#13405) Co-authored-by: OpenHands CVE Fix Bot --- enterprise/poetry.lock | 12 ++++++------ poetry.lock | 12 ++++++------ pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index b1cca69bc5..e4ac41f488 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -6276,7 +6276,7 @@ protobuf = ">=5.29.6,<6" psutil = "*" pybase62 = ">=1" pygithub = ">=2.5" -pyjwt = ">=2.9" +pyjwt = ">=2.12.0" pylatexenc = "*" pypdf = ">=6.7.2" python-docx = "*" @@ -7917,14 +7917,14 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, + {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, + {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, ] [package.dependencies] @@ -7932,9 +7932,9 @@ cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryp [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pylatexenc" diff --git a/poetry.lock b/poetry.lock index 6581b648dd..8654e22e78 100644 --- a/poetry.lock +++ b/poetry.lock @@ -7909,14 +7909,14 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, + {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, + {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, ] [package.dependencies] @@ -7924,9 +7924,9 @@ cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryp [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pylatexenc" @@ -14833,4 +14833,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api [metadata] lock-version = "2.1" python-versions = "^3.12,<3.14" -content-hash = "3eeaae126c61083ae45387053d27c17072ffbaa4ce94e9e7cea32df79a06046a" +content-hash = "b8a9c6245f0c3cabfeaffe6eb7c1fae76391a15533c18bce1fe168e070a66d63" diff --git a/pyproject.toml b/pyproject.toml index b1bfd86514..b2958f646c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "psutil", "pybase62>=1", "pygithub>=2.5", - "pyjwt>=2.9", + "pyjwt>=2.12", "pylatexenc", "pypdf>=6.7.2", "python-docx", @@ -190,7 +190,7 @@ python-multipart = ">=0.0.22" tenacity = ">=8.5,<10.0" zope-interface = "7.2" pathspec = "^0.12.1" -pyjwt = "^2.9.0" +pyjwt = "^2.12.0" dirhash = "*" tornado = ">=6.5" python-dotenv = "*" diff --git a/uv.lock b/uv.lock index 7053c0d6c2..36aef2fc9e 100644 --- a/uv.lock +++ b/uv.lock @@ -3844,7 +3844,7 @@ requires-dist = [ { name = "psutil" }, { name = "pybase62", specifier = ">=1" }, { name = "pygithub", specifier = ">=2.5" }, - { name = "pyjwt", specifier = ">=2.9" }, + { name = "pyjwt", specifier = ">=2.12.0" }, { name = "pylatexenc" }, { name = "pypdf", specifier = ">=6.7.2" }, { name = "python-docx" }, @@ -4822,11 +4822,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies] From 4dfcd68153da4f60166383db3951c9535fb09f3b Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Sun, 15 Mar 2026 14:23:06 -0400 Subject: [PATCH 28/92] (Hotfix): followup messages for slack conversations (#13411) Co-authored-by: openhands --- .../storage/slack_conversation_store.py | 4 +- enterprise/tests/unit/conftest.py | 1 + .../storage/test_slack_conversation_store.py | 75 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 enterprise/tests/unit/storage/test_slack_conversation_store.py diff --git a/enterprise/storage/slack_conversation_store.py b/enterprise/storage/slack_conversation_store.py index 5fbbb0e958..53b1350df5 100644 --- a/enterprise/storage/slack_conversation_store.py +++ b/enterprise/storage/slack_conversation_store.py @@ -25,10 +25,10 @@ class SlackConversationStore: return result.scalar_one_or_none() async def create_slack_conversation( - self, slack_converstion: SlackConversation + self, slack_conversation: SlackConversation ) -> None: async with a_session_maker() as session: - session.merge(slack_converstion) + await session.merge(slack_conversation) await session.commit() @classmethod diff --git a/enterprise/tests/unit/conftest.py b/enterprise/tests/unit/conftest.py index c273f81423..e0a929a722 100644 --- a/enterprise/tests/unit/conftest.py +++ b/enterprise/tests/unit/conftest.py @@ -28,6 +28,7 @@ from storage.org import Org from storage.org_invitation import OrgInvitation # noqa: F401 from storage.org_member import OrgMember from storage.role import Role +from storage.slack_conversation import SlackConversation # noqa: F401 from storage.stored_conversation_metadata import StoredConversationMetadata from storage.stored_conversation_metadata_saas import ( StoredConversationMetadataSaas, diff --git a/enterprise/tests/unit/storage/test_slack_conversation_store.py b/enterprise/tests/unit/storage/test_slack_conversation_store.py new file mode 100644 index 0000000000..6a7e83a2b7 --- /dev/null +++ b/enterprise/tests/unit/storage/test_slack_conversation_store.py @@ -0,0 +1,75 @@ +"""Unit tests for SlackConversationStore.""" + +from unittest.mock import patch + +import pytest +from sqlalchemy import select +from storage.slack_conversation import SlackConversation +from storage.slack_conversation_store import SlackConversationStore + + +@pytest.fixture +def slack_conversation_store(): + """Create SlackConversationStore instance.""" + return SlackConversationStore() + + +class TestSlackConversationStore: + """Test cases for SlackConversationStore.""" + + @pytest.mark.asyncio + async def test_create_slack_conversation_persists_to_database( + self, slack_conversation_store, async_session_maker + ): + """Test that create_slack_conversation actually stores data in the database. + + This test verifies that the await statement is present before session.merge(). + Without the await, the data won't be persisted and subsequent lookups will + return None even though we just created the conversation. + """ + channel_id = 'C123456' + parent_id = '1234567890.123456' + conversation_id = 'conv-test-123' + keycloak_user_id = 'user-123' + + slack_conversation = SlackConversation( + conversation_id=conversation_id, + channel_id=channel_id, + keycloak_user_id=keycloak_user_id, + parent_id=parent_id, + ) + + with patch( + 'storage.slack_conversation_store.a_session_maker', async_session_maker + ): + # Create the slack conversation + await slack_conversation_store.create_slack_conversation(slack_conversation) + + # Verify we can retrieve the conversation using the store method + result = await slack_conversation_store.get_slack_conversation( + channel_id=channel_id, + parent_id=parent_id, + ) + + # This assertion would fail if the await was missing before session.merge() + # because the data wouldn't be persisted to the database + assert result is not None, ( + 'Slack conversation was not persisted to the database. ' + 'Ensure await is used before session.merge() in create_slack_conversation.' + ) + assert result.conversation_id == conversation_id + assert result.channel_id == channel_id + assert result.parent_id == parent_id + assert result.keycloak_user_id == keycloak_user_id + + # Also verify directly in the database + async with async_session_maker() as session: + db_result = await session.execute( + select(SlackConversation).where( + SlackConversation.channel_id == channel_id, + SlackConversation.parent_id == parent_id, + ) + ) + db_conversation = db_result.scalar_one_or_none() + assert db_conversation is not None + assert db_conversation.conversation_id == conversation_id From d591b140c8039ceecb589e4d3e9cf67881d16bc1 Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Mon, 16 Mar 2026 05:19:31 -0600 Subject: [PATCH 29/92] feat: Add configurable sandbox reuse with grouping strategies (#11922) Co-authored-by: openhands --- .../100_add_sandbox_grouping_strategy.py | 33 ++++ enterprise/storage/org.py | 1 + enterprise/storage/saas_settings_store.py | 3 + enterprise/storage/user.py | 1 + enterprise/storage/user_settings.py | 1 + frontend/__tests__/utils/get-git-path.test.ts | 22 ++- frontend/src/hooks/query/use-settings.ts | 3 + .../query/use-unified-get-git-changes.ts | 2 +- .../src/hooks/query/use-unified-git-diff.ts | 2 +- frontend/src/i18n/declaration.ts | 6 + frontend/src/i18n/translation.json | 96 +++++++++ frontend/src/routes/app-settings.tsx | 50 +++++ frontend/src/services/settings.ts | 1 + frontend/src/types/settings.ts | 12 ++ frontend/src/utils/feature-flags.ts | 2 + frontend/src/utils/get-git-path.ts | 5 +- .../app_conversation_models.py | 4 + .../live_status_app_conversation_service.py | 185 ++++++++++++++++-- .../server/routes/manage_conversations.py | 34 +++- openhands/storage/data_models/settings.py | 18 ++ ...st_live_status_app_conversation_service.py | 131 +++++++++++-- .../server/data_models/test_conversation.py | 3 + 22 files changed, 569 insertions(+), 46 deletions(-) create mode 100644 enterprise/migrations/versions/100_add_sandbox_grouping_strategy.py diff --git a/enterprise/migrations/versions/100_add_sandbox_grouping_strategy.py b/enterprise/migrations/versions/100_add_sandbox_grouping_strategy.py new file mode 100644 index 0000000000..e58f6fbcf6 --- /dev/null +++ b/enterprise/migrations/versions/100_add_sandbox_grouping_strategy.py @@ -0,0 +1,33 @@ +"""Add sandbox_grouping_strategy column to user, org, and user_settings tables. + +Revision ID: 100 +Revises: 099 +Create Date: 2025-03-12 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '100' +down_revision = '099' + + +def upgrade() -> None: + op.add_column( + 'user', + sa.Column('sandbox_grouping_strategy', sa.String, nullable=True), + ) + op.add_column( + 'org', + sa.Column('sandbox_grouping_strategy', sa.String, nullable=True), + ) + op.add_column( + 'user_settings', + sa.Column('sandbox_grouping_strategy', sa.String, nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('user_settings', 'sandbox_grouping_strategy') + op.drop_column('org', 'sandbox_grouping_strategy') + op.drop_column('user', 'sandbox_grouping_strategy') diff --git a/enterprise/storage/org.py b/enterprise/storage/org.py index b0ec98b0a2..3b0b898fd1 100644 --- a/enterprise/storage/org.py +++ b/enterprise/storage/org.py @@ -47,6 +47,7 @@ class Org(Base): # type: ignore conversation_expiration = Column(Integer, nullable=True) condenser_max_size = Column(Integer, nullable=True) byor_export_enabled = Column(Boolean, nullable=False, default=False) + sandbox_grouping_strategy = Column(String, nullable=True) # Relationships org_members = relationship('OrgMember', back_populates='org') diff --git a/enterprise/storage/saas_settings_store.py b/enterprise/storage/saas_settings_store.py index d062ff6e7e..b2fbdac2bd 100644 --- a/enterprise/storage/saas_settings_store.py +++ b/enterprise/storage/saas_settings_store.py @@ -117,6 +117,9 @@ class SaasSettingsStore(SettingsStore): kwargs['llm_base_url'] = org_member.llm_base_url if org.v1_enabled is None: kwargs['v1_enabled'] = True + # Apply default if sandbox_grouping_strategy is None in the database + if kwargs.get('sandbox_grouping_strategy') is None: + kwargs.pop('sandbox_grouping_strategy', None) settings = Settings(**kwargs) return settings diff --git a/enterprise/storage/user.py b/enterprise/storage/user.py index adedf85366..2df86a7039 100644 --- a/enterprise/storage/user.py +++ b/enterprise/storage/user.py @@ -33,6 +33,7 @@ class User(Base): # type: ignore email_verified = Column(Boolean, nullable=True) git_user_name = Column(String, nullable=True) git_user_email = Column(String, nullable=True) + sandbox_grouping_strategy = Column(String, nullable=True) # Relationships role = relationship('Role', back_populates='users') diff --git a/enterprise/storage/user_settings.py b/enterprise/storage/user_settings.py index 96ccc9653e..3e62c3e930 100644 --- a/enterprise/storage/user_settings.py +++ b/enterprise/storage/user_settings.py @@ -27,6 +27,7 @@ class UserSettings(Base): # type: ignore ) sandbox_base_container_image = Column(String, nullable=True) sandbox_runtime_container_image = Column(String, nullable=True) + sandbox_grouping_strategy = Column(String, nullable=True) user_version = Column(Integer, nullable=False, default=0) accepted_tos = Column(DateTime, nullable=True) mcp_config = Column(JSON, nullable=True) diff --git a/frontend/__tests__/utils/get-git-path.test.ts b/frontend/__tests__/utils/get-git-path.test.ts index d1507228fc..2adfc232d4 100644 --- a/frontend/__tests__/utils/get-git-path.test.ts +++ b/frontend/__tests__/utils/get-git-path.test.ts @@ -2,27 +2,29 @@ import { describe, it, expect } from "vitest"; import { getGitPath } from "#/utils/get-git-path"; describe("getGitPath", () => { - it("should return /workspace/project when no repository is selected", () => { - expect(getGitPath(null)).toBe("/workspace/project"); - expect(getGitPath(undefined)).toBe("/workspace/project"); + const conversationId = "abc123"; + + it("should return /workspace/project/{conversationId} when no repository is selected", () => { + expect(getGitPath(conversationId, null)).toBe(`/workspace/project/${conversationId}`); + expect(getGitPath(conversationId, undefined)).toBe(`/workspace/project/${conversationId}`); }); it("should handle standard owner/repo format (GitHub)", () => { - expect(getGitPath("OpenHands/OpenHands")).toBe("/workspace/project/OpenHands"); - expect(getGitPath("facebook/react")).toBe("/workspace/project/react"); + expect(getGitPath(conversationId, "OpenHands/OpenHands")).toBe(`/workspace/project/${conversationId}/OpenHands`); + expect(getGitPath(conversationId, "facebook/react")).toBe(`/workspace/project/${conversationId}/react`); }); it("should handle nested group paths (GitLab)", () => { - expect(getGitPath("modernhealth/frontend-guild/pan")).toBe("/workspace/project/pan"); - expect(getGitPath("group/subgroup/repo")).toBe("/workspace/project/repo"); - expect(getGitPath("a/b/c/d/repo")).toBe("/workspace/project/repo"); + expect(getGitPath(conversationId, "modernhealth/frontend-guild/pan")).toBe(`/workspace/project/${conversationId}/pan`); + expect(getGitPath(conversationId, "group/subgroup/repo")).toBe(`/workspace/project/${conversationId}/repo`); + expect(getGitPath(conversationId, "a/b/c/d/repo")).toBe(`/workspace/project/${conversationId}/repo`); }); it("should handle single segment paths", () => { - expect(getGitPath("repo")).toBe("/workspace/project/repo"); + expect(getGitPath(conversationId, "repo")).toBe(`/workspace/project/${conversationId}/repo`); }); it("should handle empty string", () => { - expect(getGitPath("")).toBe("/workspace/project"); + expect(getGitPath(conversationId, "")).toBe(`/workspace/project/${conversationId}`); }); }); diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index ce01e4f69b..6c6d766b69 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -18,6 +18,9 @@ const getSettingsQueryFn = async (): Promise => { git_user_email: settings.git_user_email || DEFAULT_SETTINGS.git_user_email, is_new_user: false, v1_enabled: settings.v1_enabled ?? DEFAULT_SETTINGS.v1_enabled, + sandbox_grouping_strategy: + settings.sandbox_grouping_strategy ?? + DEFAULT_SETTINGS.sandbox_grouping_strategy, }; }; diff --git a/frontend/src/hooks/query/use-unified-get-git-changes.ts b/frontend/src/hooks/query/use-unified-get-git-changes.ts index 6b0856031c..70bc5f451f 100644 --- a/frontend/src/hooks/query/use-unified-get-git-changes.ts +++ b/frontend/src/hooks/query/use-unified-get-git-changes.ts @@ -27,7 +27,7 @@ export const useUnifiedGetGitChanges = () => { // Calculate git path based on selected repository const gitPath = React.useMemo( - () => getGitPath(selectedRepository), + () => getGitPath(conversationId, selectedRepository), [selectedRepository], ); diff --git a/frontend/src/hooks/query/use-unified-git-diff.ts b/frontend/src/hooks/query/use-unified-git-diff.ts index 33fedb497b..26bca16fce 100644 --- a/frontend/src/hooks/query/use-unified-git-diff.ts +++ b/frontend/src/hooks/query/use-unified-git-diff.ts @@ -32,7 +32,7 @@ export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => { const absoluteFilePath = React.useMemo(() => { if (!isV1Conversation) return config.filePath; - const gitPath = getGitPath(selectedRepository); + const gitPath = getGitPath(conversationId, selectedRepository); return `${gitPath}/${config.filePath}`; }, [isV1Conversation, selectedRepository, config.filePath]); diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 10e9d885fd..648143fc2e 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -175,6 +175,12 @@ export enum I18nKey { SETTINGS$MAX_BUDGET_PER_CONVERSATION = "SETTINGS$MAX_BUDGET_PER_CONVERSATION", SETTINGS$PROACTIVE_CONVERSATION_STARTERS = "SETTINGS$PROACTIVE_CONVERSATION_STARTERS", SETTINGS$SOLVABILITY_ANALYSIS = "SETTINGS$SOLVABILITY_ANALYSIS", + SETTINGS$SANDBOX_GROUPING_STRATEGY = "SETTINGS$SANDBOX_GROUPING_STRATEGY", + SETTINGS$SANDBOX_GROUPING_NO_GROUPING = "SETTINGS$SANDBOX_GROUPING_NO_GROUPING", + SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST = "SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST", + SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED = "SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED", + SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS = "SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS", + SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY = "SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY", SETTINGS$SEARCH_API_KEY = "SETTINGS$SEARCH_API_KEY", SETTINGS$SEARCH_API_KEY_OPTIONAL = "SETTINGS$SEARCH_API_KEY_OPTIONAL", SETTINGS$SEARCH_API_KEY_INSTRUCTIONS = "SETTINGS$SEARCH_API_KEY_INSTRUCTIONS", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index abeba30110..d3f91ceec7 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -2799,6 +2799,102 @@ "tr": "Çözünürlük Analizini Etkinleştir", "uk": "Увімкнути аналіз розв'язності" }, + "SETTINGS$SANDBOX_GROUPING_STRATEGY": { + "en": "Sandbox Grouping Strategy", + "ja": "サンドボックスグループ化戦略", + "zh-CN": "沙盒分组策略", + "zh-TW": "沙盒分組策略", + "ko-KR": "샌드박스 그룹화 전략", + "de": "Sandbox-Gruppierungsstrategie", + "no": "Sandkasse-grupperingsstrategi", + "it": "Strategia di raggruppamento sandbox", + "pt": "Estratégia de agrupamento de sandbox", + "es": "Estrategia de agrupación de sandbox", + "ar": "استراتيجية تجميع صندوق الرمل", + "fr": "Stratégie de regroupement sandbox", + "tr": "Sandbox Gruplama Stratejisi", + "uk": "Стратегія групування пісочниці" + }, + "SETTINGS$SANDBOX_GROUPING_NO_GROUPING": { + "en": "No Grouping (new sandbox per conversation)", + "ja": "グループ化なし (会話ごとに新しいサンドボックス)", + "zh-CN": "不分组 (每个对话使用新沙盒)", + "zh-TW": "不分組 (每個對話使用新沙盒)", + "ko-KR": "그룹화 없음 (대화마다 새 샌드박스)", + "de": "Keine Gruppierung (neue Sandbox pro Gespräch)", + "no": "Ingen gruppering (ny sandkasse per samtale)", + "it": "Nessun raggruppamento (nuova sandbox per conversazione)", + "pt": "Sem agrupamento (novo sandbox por conversa)", + "es": "Sin agrupación (nuevo sandbox por conversación)", + "ar": "بدون تجميع (صندوق رمل جديد لكل محادثة)", + "fr": "Pas de regroupement (nouveau sandbox par conversation)", + "tr": "Gruplama Yok (konuşma başına yeni sandbox)", + "uk": "Без групування (нова пісочниця для кожної розмови)" + }, + "SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST": { + "en": "Group by Newest (add to most recent sandbox)", + "ja": "最新でグループ化 (最新のサンドボックスに追加)", + "zh-CN": "按最新分组 (添加到最近的沙盒)", + "zh-TW": "按最新分組 (添加到最近的沙盒)", + "ko-KR": "최신으로 그룹화 (가장 최근 샌드박스에 추가)", + "de": "Nach neuester gruppieren (zur neuesten Sandbox hinzufügen)", + "no": "Grupper etter nyeste (legg til i nyeste sandkasse)", + "it": "Raggruppa per più recente (aggiungi alla sandbox più recente)", + "pt": "Agrupar por mais recente (adicionar ao sandbox mais recente)", + "es": "Agrupar por más reciente (agregar al sandbox más reciente)", + "ar": "التجميع حسب الأحدث (إضافة إلى أحدث صندوق رمل)", + "fr": "Regrouper par le plus récent (ajouter au sandbox le plus récent)", + "tr": "En Yeniye Göre Grupla (en yeni sandbox'a ekle)", + "uk": "Групувати за найновішим (додати до найновішої пісочниці)" + }, + "SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED": { + "en": "Least Recently Used (add to oldest sandbox)", + "ja": "最も古い (最も古いサンドボックスに追加)", + "zh-CN": "最近最少使用 (添加到最旧的沙盒)", + "zh-TW": "最近最少使用 (添加到最舊的沙盒)", + "ko-KR": "가장 오래된 것 (가장 오래된 샌드박스에 추가)", + "de": "Am ältesten (zur ältesten Sandbox hinzufügen)", + "no": "Eldst (legg til i eldste sandkasse)", + "it": "Meno usato di recente (aggiungi alla sandbox più vecchia)", + "pt": "Menos usado recentemente (adicionar ao sandbox mais antigo)", + "es": "Menos usado recientemente (agregar al sandbox más antiguo)", + "ar": "الأقل استخدامًا مؤخرًا (إضافة إلى أقدم صندوق رمل)", + "fr": "Le moins récemment utilisé (ajouter au sandbox le plus ancien)", + "tr": "En Az Kullanılan (en eski sandbox'a ekle)", + "uk": "Найменш нещодавно використана (додати до найстаршої пісочниці)" + }, + "SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS": { + "en": "Fewest Conversations (add to least busy sandbox)", + "ja": "会話数が最少 (最も空いているサンドボックスに追加)", + "zh-CN": "最少对话 (添加到最空闲的沙盒)", + "zh-TW": "最少對話 (添加到最空閒的沙盒)", + "ko-KR": "대화 수가 가장 적은 (가장 한가한 샌드박스에 추가)", + "de": "Wenigste Gespräche (zur am wenigsten beschäftigten Sandbox hinzufügen)", + "no": "Færrest samtaler (legg til i minst opptatt sandkasse)", + "it": "Meno conversazioni (aggiungi alla sandbox meno occupata)", + "pt": "Menos conversas (adicionar ao sandbox menos ocupado)", + "es": "Menos conversaciones (agregar al sandbox menos ocupado)", + "ar": "أقل محادثات (إضافة إلى صندوق الرمل الأقل انشغالاً)", + "fr": "Moins de conversations (ajouter au sandbox le moins occupé)", + "tr": "En Az Konuşma (en az meşgul sandbox'a ekle)", + "uk": "Найменше розмов (додати до найменш зайнятої пісочниці)" + }, + "SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY": { + "en": "Add to Any (use first available sandbox)", + "ja": "任意に追加 (最初に利用可能なサンドボックスを使用)", + "zh-CN": "添加到任意 (使用第一个可用的沙盒)", + "zh-TW": "添加到任意 (使用第一個可用的沙盒)", + "ko-KR": "아무 곳에나 추가 (첫 번째 사용 가능한 샌드박스 사용)", + "de": "Zu beliebig hinzufügen (erste verfügbare Sandbox verwenden)", + "no": "Legg til i hvilken som helst (bruk første tilgjengelige sandkasse)", + "it": "Aggiungi a qualsiasi (usa la prima sandbox disponibile)", + "pt": "Adicionar a qualquer (usar o primeiro sandbox disponível)", + "es": "Agregar a cualquiera (usar el primer sandbox disponible)", + "ar": "إضافة إلى أي (استخدام أول صندوق رمل متاح)", + "fr": "Ajouter à n'importe lequel (utiliser le premier sandbox disponible)", + "tr": "Herhangi Birine Ekle (ilk uygun sandbox'ı kullan)", + "uk": "Додати до будь-якої (використовувати першу доступну пісочницю)" + }, "SETTINGS$SEARCH_API_KEY": { "en": "Search API Key (Tavily)", "ja": "検索APIキー (Tavily)", diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx index 8226488468..43753fbec7 100644 --- a/frontend/src/routes/app-settings.tsx +++ b/frontend/src/routes/app-settings.tsx @@ -8,6 +8,7 @@ import { DEFAULT_SETTINGS } from "#/services/settings"; import { BrandButton } from "#/components/features/settings/brand-button"; import { SettingsSwitch } from "#/components/features/settings/settings-switch"; import { SettingsInput } from "#/components/features/settings/settings-input"; +import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input"; import { I18nKey } from "#/i18n/declaration"; import { LanguageInput } from "#/components/features/settings/app-settings/language-input"; import { handleCaptureConsent } from "#/utils/handle-capture-consent"; @@ -19,6 +20,11 @@ import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message" import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton"; import { useConfig } from "#/hooks/query/use-config"; import { parseMaxBudgetPerTask } from "#/utils/settings-utils"; +import { + SandboxGroupingStrategy, + SandboxGroupingStrategyOptions, +} from "#/types/settings"; +import { ENABLE_SANDBOX_GROUPING } from "#/utils/feature-flags"; import { createPermissionGuard } from "#/utils/org/permission-guard"; export const clientLoader = createPermissionGuard( @@ -49,6 +55,12 @@ function AppSettingsScreen() { solvabilityAnalysisSwitchHasChanged, setSolvabilityAnalysisSwitchHasChanged, ] = React.useState(false); + const [ + sandboxGroupingStrategyHasChanged, + setSandboxGroupingStrategyHasChanged, + ] = React.useState(false); + const [selectedSandboxGroupingStrategy, setSelectedSandboxGroupingStrategy] = + React.useState(null); const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] = React.useState(false); const [gitUserNameHasChanged, setGitUserNameHasChanged] = @@ -75,6 +87,11 @@ function AppSettingsScreen() { const enableSolvabilityAnalysis = formData.get("enable-solvability-analysis-switch")?.toString() === "on"; + const sandboxGroupingStrategy = + selectedSandboxGroupingStrategy || + settings?.sandbox_grouping_strategy || + DEFAULT_SETTINGS.sandbox_grouping_strategy; + const maxBudgetPerTaskValue = formData .get("max-budget-per-task-input") ?.toString(); @@ -94,6 +111,7 @@ function AppSettingsScreen() { enable_sound_notifications: enableSoundNotifications, enable_proactive_conversation_starters: enableProactiveConversations, enable_solvability_analysis: enableSolvabilityAnalysis, + sandbox_grouping_strategy: sandboxGroupingStrategy, max_budget_per_task: maxBudgetPerTask, git_user_name: gitUserName, git_user_email: gitUserEmail, @@ -112,6 +130,8 @@ function AppSettingsScreen() { setAnalyticsSwitchHasChanged(false); setSoundNotificationsSwitchHasChanged(false); setProactiveConversationsSwitchHasChanged(false); + setSandboxGroupingStrategyHasChanged(false); + setSelectedSandboxGroupingStrategy(null); setMaxBudgetPerTaskHasChanged(false); setGitUserNameHasChanged(false); setGitUserEmailHasChanged(false); @@ -159,6 +179,15 @@ function AppSettingsScreen() { ); }; + const handleSandboxGroupingStrategyChange = (key: React.Key | null) => { + const newStrategy = key?.toString() as SandboxGroupingStrategy | undefined; + setSelectedSandboxGroupingStrategy(newStrategy || null); + const currentStrategy = + settings?.sandbox_grouping_strategy || + DEFAULT_SETTINGS.sandbox_grouping_strategy; + setSandboxGroupingStrategyHasChanged(newStrategy !== currentStrategy); + }; + const checkIfMaxBudgetPerTaskHasChanged = (value: string) => { const newValue = parseMaxBudgetPerTask(value); const currentValue = settings?.max_budget_per_task; @@ -181,6 +210,7 @@ function AppSettingsScreen() { !soundNotificationsSwitchHasChanged && !proactiveConversationsSwitchHasChanged && !solvabilityAnalysisSwitchHasChanged && + !sandboxGroupingStrategyHasChanged && !maxBudgetPerTaskHasChanged && !gitUserNameHasChanged && !gitUserEmailHasChanged; @@ -244,6 +274,26 @@ function AppSettingsScreen() { )} + {ENABLE_SANDBOX_GROUPING() && ( + ({ + key, + label: t(`SETTINGS$SANDBOX_GROUPING_${key}` as I18nKey), + }))} + selectedKey={ + selectedSandboxGroupingStrategy || + settings.sandbox_grouping_strategy || + DEFAULT_SETTINGS.sandbox_grouping_strategy + } + isClearable={false} + onSelectionChange={handleSandboxGroupingStrategyChange} + wrapperClassName="w-full max-w-[680px]" + /> + )} + {!settings?.v1_enabled && ( loadFeatureFlag("VSCODE_IN_NEW_TAB"); export const ENABLE_TRAJECTORY_REPLAY = () => loadFeatureFlag("TRAJECTORY_REPLAY"); export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING"); +export const ENABLE_SANDBOX_GROUPING = () => + loadFeatureFlag("SANDBOX_GROUPING"); diff --git a/frontend/src/utils/get-git-path.ts b/frontend/src/utils/get-git-path.ts index 15c8ff947e..39292b819f 100644 --- a/frontend/src/utils/get-git-path.ts +++ b/frontend/src/utils/get-git-path.ts @@ -7,10 +7,11 @@ * @returns The git path to use */ export function getGitPath( + conversationId: string, selectedRepository: string | null | undefined, ): string { if (!selectedRepository) { - return "/workspace/project"; + return `/workspace/project/${conversationId}`; } // Extract the repository name from the path @@ -18,5 +19,5 @@ export function getGitPath( const parts = selectedRepository.split("/"); const repoName = parts[parts.length - 1]; - return `/workspace/project/${repoName}`; + return `/workspace/project/${conversationId}/${repoName}`; } diff --git a/openhands/app_server/app_conversation/app_conversation_models.py b/openhands/app_server/app_conversation/app_conversation_models.py index a30b40e56c..b7a4cc4dce 100644 --- a/openhands/app_server/app_conversation/app_conversation_models.py +++ b/openhands/app_server/app_conversation/app_conversation_models.py @@ -16,6 +16,10 @@ from openhands.sdk.conversation.state import ConversationExecutionStatus from openhands.sdk.llm import MetricsSnapshot from openhands.sdk.plugin import PluginSource from openhands.storage.data_models.conversation_metadata import ConversationTrigger +from openhands.storage.data_models.settings import SandboxGroupingStrategy + +# Re-export SandboxGroupingStrategy for backward compatibility +__all__ = ['SandboxGroupingStrategy'] class AgentType(Enum): diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 94b5740329..7bb59fedbf 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -88,6 +88,7 @@ from openhands.sdk.utils.paging import page_iterator from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.server.types import AppMode from openhands.storage.data_models.conversation_metadata import ConversationTrigger +from openhands.storage.data_models.settings import SandboxGroupingStrategy from openhands.tools.preset.default import ( get_default_tools, ) @@ -128,6 +129,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): jwt_service: JwtService sandbox_startup_timeout: int sandbox_startup_poll_frequency: int + max_num_conversations_per_sandbox: int httpx_client: httpx.AsyncClient web_url: str | None openhands_provider_base_url: str | None @@ -135,6 +137,11 @@ class LiveStatusAppConversationService(AppConversationServiceBase): app_mode: str | None = None tavily_api_key: str | None = None + async def _get_sandbox_grouping_strategy(self) -> SandboxGroupingStrategy: + """Get the sandbox grouping strategy from user settings.""" + user_info = await self.user_context.get_user_info() + return user_info.sandbox_grouping_strategy + async def search_app_conversations( self, title__contains: str | None = None, @@ -255,11 +262,20 @@ class LiveStatusAppConversationService(AppConversationServiceBase): ) assert sandbox_spec is not None + # Set up conversation id + conversation_id = request.conversation_id or uuid4() + + # Setup working dir based on grouping + working_dir = sandbox_spec.working_dir + sandbox_grouping_strategy = await self._get_sandbox_grouping_strategy() + if sandbox_grouping_strategy != SandboxGroupingStrategy.NO_GROUPING: + working_dir = f'{working_dir}/{conversation_id.hex}' + # Run setup scripts remote_workspace = AsyncRemoteWorkspace( host=agent_server_url, api_key=sandbox.session_api_key, - working_dir=sandbox_spec.working_dir, + working_dir=working_dir, ) async for updated_task in self.run_setup_scripts( task, sandbox, remote_workspace, agent_server_url @@ -270,13 +286,13 @@ class LiveStatusAppConversationService(AppConversationServiceBase): start_conversation_request = ( await self._build_start_conversation_request_for_user( sandbox, + conversation_id, request.initial_message, request.system_message_suffix, request.git_provider, - sandbox_spec.working_dir, + working_dir, request.agent_type, request.llm_model, - request.conversation_id, remote_workspace=remote_workspace, selected_repository=request.selected_repository, plugins=request.plugins, @@ -495,21 +511,157 @@ class LiveStatusAppConversationService(AppConversationServiceBase): result[stored_conversation.sandbox_id].append(stored_conversation.id) return result + async def _find_running_sandbox_for_user(self) -> SandboxInfo | None: + """Find a running sandbox for the current user based on the grouping strategy. + + Returns: + SandboxInfo if a running sandbox is found, None otherwise. + """ + try: + user_id = await self.user_context.get_user_id() + sandbox_grouping_strategy = await self._get_sandbox_grouping_strategy() + + # If no grouping, return None to force creation of a new sandbox + if sandbox_grouping_strategy == SandboxGroupingStrategy.NO_GROUPING: + return None + + # Collect all running sandboxes for this user + running_sandboxes = [] + page_id = None + while True: + page = await self.sandbox_service.search_sandboxes( + page_id=page_id, limit=100 + ) + + for sandbox in page.items: + if ( + sandbox.status == SandboxStatus.RUNNING + and sandbox.created_by_user_id == user_id + ): + running_sandboxes.append(sandbox) + + if page.next_page_id is None: + break + page_id = page.next_page_id + + if not running_sandboxes: + return None + + # Apply the grouping strategy + return await self._select_sandbox_by_strategy( + running_sandboxes, sandbox_grouping_strategy + ) + + except Exception as e: + _logger.warning( + f'Error finding running sandbox for user: {e}', exc_info=True + ) + return None + + async def _select_sandbox_by_strategy( + self, + running_sandboxes: list[SandboxInfo], + sandbox_grouping_strategy: SandboxGroupingStrategy, + ) -> SandboxInfo | None: + """Select a sandbox from the list based on the configured grouping strategy. + + Args: + running_sandboxes: List of running sandboxes for the user + sandbox_grouping_strategy: The strategy to use for selection + + Returns: + Selected sandbox based on the strategy, or None if no sandbox is available + (e.g., all sandboxes have reached max_num_conversations_per_sandbox) + """ + # Get conversation counts for filtering by max_num_conversations_per_sandbox + sandbox_conversation_counts = await self._get_conversation_counts_by_sandbox( + [s.id for s in running_sandboxes] + ) + + # Filter out sandboxes that have reached the max number of conversations + available_sandboxes = [ + s + for s in running_sandboxes + if sandbox_conversation_counts.get(s.id, 0) + < self.max_num_conversations_per_sandbox + ] + + if not available_sandboxes: + # All sandboxes have reached the max - need to create a new one + return None + + if sandbox_grouping_strategy == SandboxGroupingStrategy.ADD_TO_ANY: + # Return the first available sandbox + return available_sandboxes[0] + + elif sandbox_grouping_strategy == SandboxGroupingStrategy.GROUP_BY_NEWEST: + # Return the most recently created sandbox + return max(available_sandboxes, key=lambda s: s.created_at) + + elif sandbox_grouping_strategy == SandboxGroupingStrategy.LEAST_RECENTLY_USED: + # Return the least recently created sandbox (oldest) + return min(available_sandboxes, key=lambda s: s.created_at) + + elif sandbox_grouping_strategy == SandboxGroupingStrategy.FEWEST_CONVERSATIONS: + # Return the one with fewest conversations + return min( + available_sandboxes, + key=lambda s: sandbox_conversation_counts.get(s.id, 0), + ) + + else: + # Default fallback - return first sandbox + return available_sandboxes[0] + + async def _get_conversation_counts_by_sandbox( + self, sandbox_ids: list[str] + ) -> dict[str, int]: + """Get the count of conversations for each sandbox. + + Args: + sandbox_ids: List of sandbox IDs to count conversations for + + Returns: + Dictionary mapping sandbox_id to conversation count + """ + try: + # Query count for each sandbox individually + # This is efficient since there are at most ~8 running sandboxes per user + counts: dict[str, int] = {} + for sandbox_id in sandbox_ids: + count = await self.app_conversation_info_service.count_app_conversation_info( + sandbox_id__eq=sandbox_id + ) + counts[sandbox_id] = count + return counts + except Exception as e: + _logger.warning( + f'Error counting conversations by sandbox: {e}', exc_info=True + ) + # Return empty counts on error - will default to first sandbox + return {} + async def _wait_for_sandbox_start( self, task: AppConversationStartTask ) -> AsyncGenerator[AppConversationStartTask, None]: """Wait for sandbox to start and return info.""" # Get or create the sandbox if not task.request.sandbox_id: - # Convert conversation_id to hex string if present - sandbox_id_str = ( - task.request.conversation_id.hex - if task.request.conversation_id is not None - else None - ) - sandbox = await self.sandbox_service.start_sandbox( - sandbox_id=sandbox_id_str - ) + # First try to find a running sandbox for the current user + sandbox = await self._find_running_sandbox_for_user() + if sandbox is None: + # No running sandbox found, start a new one + + # Convert conversation_id to hex string if present + sandbox_id_str = ( + task.request.conversation_id.hex + if task.request.conversation_id is not None + else None + ) + + sandbox = await self.sandbox_service.start_sandbox( + sandbox_id=sandbox_id_str + ) task.sandbox_id = sandbox.id else: sandbox_info = await self.sandbox_service.get_sandbox( @@ -1133,7 +1285,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): async def _finalize_conversation_request( self, agent: Agent, - conversation_id: UUID | None, + conversation_id: UUID, user: UserInfo, workspace: LocalWorkspace, initial_message: SendMessageRequest | None, @@ -1211,13 +1363,13 @@ class LiveStatusAppConversationService(AppConversationServiceBase): async def _build_start_conversation_request_for_user( self, sandbox: SandboxInfo, + conversation_id: UUID, initial_message: SendMessageRequest | None, system_message_suffix: str | None, git_provider: ProviderType | None, working_dir: str, agent_type: AgentType = AgentType.DEFAULT, llm_model: str | None = None, - conversation_id: UUID | None = None, remote_workspace: AsyncRemoteWorkspace | None = None, selected_repository: str | None = None, plugins: list[PluginSpec] | None = None, @@ -1614,6 +1766,10 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): sandbox_startup_poll_frequency: int = Field( default=2, description='The frequency to poll for sandbox readiness' ) + max_num_conversations_per_sandbox: int = Field( + default=20, + description='The maximum number of conversations allowed per sandbox', + ) init_git_in_empty_workspace: bool = Field( default=True, description='Whether to initialize a git repo when the workspace is empty', @@ -1705,6 +1861,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): jwt_service=jwt_service, sandbox_startup_timeout=self.sandbox_startup_timeout, sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency, + max_num_conversations_per_sandbox=self.max_num_conversations_per_sandbox, httpx_client=httpx_client, web_url=web_url, openhands_provider_base_url=config.openhands_provider_base_url, diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 547ca6e252..fa73aa4d52 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -41,7 +41,7 @@ from openhands.app_server.config import ( depends_httpx_client, depends_sandbox_service, ) -from openhands.app_server.sandbox.sandbox_models import SandboxStatus +from openhands.app_server.sandbox.sandbox_models import AGENT_SERVER, SandboxStatus from openhands.app_server.sandbox.sandbox_service import SandboxService from openhands.app_server.services.db_session_injector import set_db_session_keep_open from openhands.app_server.services.httpx_client_injector import ( @@ -614,7 +614,7 @@ async def _try_delete_v1_conversation( # Delete the sandbox in the background asyncio.create_task( - _delete_sandbox_and_close_connections( + _finalize_delete_and_close_connections( sandbox_service, app_conversation_info.sandbox_id, db_session, @@ -628,14 +628,18 @@ async def _try_delete_v1_conversation( return result -async def _delete_sandbox_and_close_connections( +async def _finalize_delete_and_close_connections( sandbox_service: SandboxService, sandbox_id: str, db_session: AsyncSession, httpx_client: httpx.AsyncClient, ): try: - await sandbox_service.delete_sandbox(sandbox_id) + num_conversations_in_sandbox = await _get_num_conversations_in_sandbox( + sandbox_service, sandbox_id, httpx_client + ) + if num_conversations_in_sandbox == 0: + await sandbox_service.delete_sandbox(sandbox_id) await db_session.commit() finally: await asyncio.gather( @@ -646,6 +650,28 @@ async def _delete_sandbox_and_close_connections( ) +async def _get_num_conversations_in_sandbox( + sandbox_service: SandboxService, + sandbox_id: str, + httpx_client: httpx.AsyncClient, +) -> int: + try: + sandbox = await sandbox_service.get_sandbox(sandbox_id) + if not sandbox or not sandbox.exposed_urls: + return 0 + agent_server_url = next( + u for u in sandbox.exposed_urls if u.name == AGENT_SERVER + ) + response = await httpx_client.get( + f'{agent_server_url.url}/api/conversations/count', + headers={'X-Session-API-Key': sandbox.session_api_key}, + ) + result = int(response.content) + return result + except Exception: + return 0 + + async def _delete_v0_conversation(conversation_id: str, user_id: str | None) -> bool: """Delete a V0 conversation using the legacy logic.""" conversation_store = await ConversationStoreImpl.get_instance(config, user_id) diff --git a/openhands/storage/data_models/settings.py b/openhands/storage/data_models/settings.py index 1600acd3ad..a27c4e1b20 100644 --- a/openhands/storage/data_models/settings.py +++ b/openhands/storage/data_models/settings.py @@ -1,5 +1,6 @@ from __future__ import annotations +from enum import Enum from typing import Annotated from pydantic import ( @@ -19,6 +20,20 @@ from openhands.core.config.utils import load_openhands_config from openhands.storage.data_models.secrets import Secrets +class SandboxGroupingStrategy(str, Enum): + """Strategy for grouping conversations within sandboxes.""" + + NO_GROUPING = 'NO_GROUPING' # Default - each conversation gets its own sandbox + GROUP_BY_NEWEST = 'GROUP_BY_NEWEST' # Add to the most recently created sandbox + LEAST_RECENTLY_USED = ( + 'LEAST_RECENTLY_USED' # Add to the least recently used sandbox + ) + FEWEST_CONVERSATIONS = ( + 'FEWEST_CONVERSATIONS' # Add to sandbox with fewest conversations + ) + ADD_TO_ANY = 'ADD_TO_ANY' # Add to any available sandbox (first found) + + class Settings(BaseModel): """Persisted settings for OpenHands sessions""" @@ -54,6 +69,9 @@ class Settings(BaseModel): git_user_name: str | None = None git_user_email: str | None = None v1_enabled: bool = True + sandbox_grouping_strategy: SandboxGroupingStrategy = ( + SandboxGroupingStrategy.NO_GROUPING + ) model_config = ConfigDict( validate_assignment=True, diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index ad9b4edb46..cf32cfaf05 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -6,7 +6,7 @@ import os import zipfile from datetime import datetime from unittest.mock import AsyncMock, Mock, patch -from uuid import UUID, uuid4 +from uuid import uuid4 import pytest from pydantic import SecretStr @@ -29,6 +29,7 @@ from openhands.app_server.sandbox.sandbox_models import ( AGENT_SERVER, ExposedUrl, SandboxInfo, + SandboxPage, SandboxStatus, ) from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo @@ -42,6 +43,7 @@ from openhands.sdk.workspace import LocalWorkspace from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.server.types import AppMode from openhands.storage.data_models.conversation_metadata import ConversationTrigger +from openhands.storage.data_models.settings import SandboxGroupingStrategy # Env var used by openhands SDK LLM to skip context-window validation (e.g. for gpt-4 in tests) _ALLOW_SHORT_CONTEXT_WINDOWS = 'ALLOW_SHORT_CONTEXT_WINDOWS' @@ -92,6 +94,7 @@ class TestLiveStatusAppConversationService: jwt_service=self.mock_jwt_service, sandbox_startup_timeout=30, sandbox_startup_poll_frequency=1, + max_num_conversations_per_sandbox=20, httpx_client=self.mock_httpx_client, web_url='https://test.example.com', openhands_provider_base_url='https://provider.example.com', @@ -105,6 +108,8 @@ class TestLiveStatusAppConversationService: self.mock_user.llm_model = 'gpt-4' self.mock_user.llm_base_url = 'https://api.openai.com/v1' self.mock_user.llm_api_key = 'test_api_key' + # Use ADD_TO_ANY for tests to maintain old behavior + self.mock_user.sandbox_grouping_strategy = SandboxGroupingStrategy.ADD_TO_ANY self.mock_user.confirmation_mode = False self.mock_user.search_api_key = None # Default to None self.mock_user.condenser_max_size = None # Default to None @@ -1091,11 +1096,12 @@ class TestLiveStatusAppConversationService: workspace = LocalWorkspace(working_dir='/test') secrets = {'test': StaticSecret(value='secret')} + test_conversation_id = uuid4() # Act result = await self.service._finalize_conversation_request( mock_agent, - None, + test_conversation_id, self.mock_user, workspace, None, @@ -1108,7 +1114,7 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(result, StartConversationRequest) - assert isinstance(result.conversation_id, UUID) + assert result.conversation_id == test_conversation_id @pytest.mark.asyncio async def test_finalize_conversation_request_skills_loading_fails(self): @@ -1179,13 +1185,13 @@ class TestLiveStatusAppConversationService: # Act result = await self.service._build_start_conversation_request_for_user( sandbox=self.mock_sandbox, + conversation_id=uuid4(), initial_message=None, system_message_suffix='Test suffix', git_provider=ProviderType.GITHUB, working_dir='/test/dir', agent_type=AgentType.DEFAULT, llm_model='gpt-4', - conversation_id=None, remote_workspace=None, selected_repository='test/repo', ) @@ -1215,6 +1221,98 @@ class TestLiveStatusAppConversationService: self.service._finalize_conversation_request.assert_called_once() @pytest.mark.asyncio + async def test_find_running_sandbox_for_user_found(self): + """Test _find_running_sandbox_for_user when a running sandbox is found.""" + # Arrange + user_id = 'test_user_123' + self.mock_user_context.get_user_id.return_value = user_id + + # Create mock sandboxes + running_sandbox = Mock(spec=SandboxInfo) + running_sandbox.id = 'sandbox_1' + running_sandbox.status = SandboxStatus.RUNNING + running_sandbox.created_by_user_id = user_id + + other_user_sandbox = Mock(spec=SandboxInfo) + other_user_sandbox.id = 'sandbox_2' + other_user_sandbox.status = SandboxStatus.RUNNING + other_user_sandbox.created_by_user_id = 'other_user' + + paused_sandbox = Mock(spec=SandboxInfo) + paused_sandbox.id = 'sandbox_3' + paused_sandbox.status = SandboxStatus.PAUSED + paused_sandbox.created_by_user_id = user_id + + # Mock sandbox service search + mock_page = Mock(spec=SandboxPage) + mock_page.items = [other_user_sandbox, running_sandbox, paused_sandbox] + mock_page.next_page_id = None + self.mock_sandbox_service.search_sandboxes = AsyncMock(return_value=mock_page) + + # Act + result = await self.service._find_running_sandbox_for_user() + + # Assert + assert result == running_sandbox + self.mock_user_context.get_user_id.assert_called_once() + self.mock_sandbox_service.search_sandboxes.assert_called_once_with( + page_id=None, limit=100 + ) + + @pytest.mark.asyncio + async def test_find_running_sandbox_for_user_not_found(self): + """Test _find_running_sandbox_for_user when no running sandbox is found.""" + # Arrange + user_id = 'test_user_123' + self.mock_user_context.get_user_id.return_value = user_id + + # Create mock sandboxes (none running for this user) + other_user_sandbox = Mock(spec=SandboxInfo) + other_user_sandbox.id = 'sandbox_1' + other_user_sandbox.status = SandboxStatus.RUNNING + other_user_sandbox.created_by_user_id = 'other_user' + + paused_sandbox = Mock(spec=SandboxInfo) + paused_sandbox.id = 'sandbox_2' + paused_sandbox.status = SandboxStatus.PAUSED + paused_sandbox.created_by_user_id = user_id + + # Mock sandbox service search + mock_page = Mock(spec=SandboxPage) + mock_page.items = [other_user_sandbox, paused_sandbox] + mock_page.next_page_id = None + self.mock_sandbox_service.search_sandboxes = AsyncMock(return_value=mock_page) + + # Act + result = await self.service._find_running_sandbox_for_user() + + # Assert + assert result is None + self.mock_user_context.get_user_id.assert_called_once() + self.mock_sandbox_service.search_sandboxes.assert_called_once_with( + page_id=None, limit=100 + ) + + @pytest.mark.asyncio + async def test_find_running_sandbox_for_user_exception_handling(self): + """Test _find_running_sandbox_for_user handles exceptions gracefully.""" + # Arrange + self.mock_user_context.get_user_id.side_effect = Exception('User context error') + + # Act + with patch( + 'openhands.app_server.app_conversation.live_status_app_conversation_service._logger' + ) as mock_logger: + result = await self.service._find_running_sandbox_for_user() + + # Assert + assert result is None + mock_logger.warning.assert_called_once() + assert ( + 'Error finding running sandbox for user' + in mock_logger.warning.call_args[0][0] + ) + async def test_export_conversation_success(self): """Test successful download of conversation trajectory.""" # Arrange @@ -2052,6 +2150,7 @@ class TestLiveStatusAppConversationService: await self.service._build_start_conversation_request_for_user( sandbox=self.mock_sandbox, + conversation_id=uuid4(), initial_message=None, system_message_suffix=None, git_provider=None, @@ -2088,6 +2187,7 @@ class TestLiveStatusAppConversationService: await self.service._build_start_conversation_request_for_user( sandbox=self.mock_sandbox, + conversation_id=uuid4(), initial_message=None, system_message_suffix=None, git_provider=None, @@ -2243,6 +2343,7 @@ class TestPluginHandling: jwt_service=self.mock_jwt_service, sandbox_startup_timeout=30, sandbox_startup_poll_frequency=1, + max_num_conversations_per_sandbox=20, httpx_client=self.mock_httpx_client, web_url='https://test.example.com', openhands_provider_base_url='https://provider.example.com', @@ -2726,11 +2827,12 @@ class TestPluginHandling: # Act await self.service._build_start_conversation_request_for_user( - self.mock_sandbox, - None, - None, - None, - '/workspace', + sandbox=self.mock_sandbox, + conversation_id=uuid4(), + initial_message=None, + system_message_suffix=None, + git_provider=None, + working_dir='/workspace', plugins=plugins, ) @@ -2754,11 +2856,12 @@ class TestPluginHandling: # Act await self.service._build_start_conversation_request_for_user( - self.mock_sandbox, - None, - None, - None, - '/workspace', + sandbox=self.mock_sandbox, + conversation_id=uuid4(), + initial_message=None, + system_message_suffix=None, + git_provider=None, + working_dir='/workspace', ) # Assert diff --git a/tests/unit/server/data_models/test_conversation.py b/tests/unit/server/data_models/test_conversation.py index fc305d170e..7fa64ab12a 100644 --- a/tests/unit/server/data_models/test_conversation.py +++ b/tests/unit/server/data_models/test_conversation.py @@ -2189,6 +2189,7 @@ async def test_delete_v1_conversation_with_sub_conversations(): jwt_service=MagicMock(), sandbox_startup_timeout=120, sandbox_startup_poll_frequency=2, + max_num_conversations_per_sandbox=20, httpx_client=mock_httpx_client, web_url=None, openhands_provider_base_url=None, @@ -2312,6 +2313,7 @@ async def test_delete_v1_conversation_with_no_sub_conversations(): jwt_service=MagicMock(), sandbox_startup_timeout=120, sandbox_startup_poll_frequency=2, + max_num_conversations_per_sandbox=20, httpx_client=mock_httpx_client, web_url=None, openhands_provider_base_url=None, @@ -2465,6 +2467,7 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error(): jwt_service=MagicMock(), sandbox_startup_timeout=120, sandbox_startup_poll_frequency=2, + max_num_conversations_per_sandbox=20, httpx_client=mock_httpx_client, web_url=None, openhands_provider_base_url=None, From aec95ecf3b39f54de1637b83e5949feb9493e994 Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Mon, 16 Mar 2026 05:20:10 -0600 Subject: [PATCH 30/92] feat(frontend): update stop sandbox dialog to display conversations in sandbox (#13388) Co-authored-by: openhands --- .../conversation/conversation-name.test.tsx | 4 +- .../v1-conversation-service.api.ts | 23 +++++ .../v1-conversation-service.types.ts | 5 ++ .../conversation-panel/confirm-stop-modal.tsx | 60 +++++++++++++ .../conversation-panel/conversation-panel.tsx | 7 ++ .../conversation/conversation-name.tsx | 1 + .../query/use-conversations-in-sandbox.ts | 15 ++++ frontend/src/i18n/translation.json | 84 +++++++++---------- 8 files changed, 155 insertions(+), 44 deletions(-) create mode 100644 frontend/src/hooks/query/use-conversations-in-sandbox.ts diff --git a/frontend/__tests__/components/features/conversation/conversation-name.test.tsx b/frontend/__tests__/components/features/conversation/conversation-name.test.tsx index 45716775cc..3152fde699 100644 --- a/frontend/__tests__/components/features/conversation/conversation-name.test.tsx +++ b/frontend/__tests__/components/features/conversation/conversation-name.test.tsx @@ -72,7 +72,7 @@ vi.mock("react-i18next", async () => { CONVERSATION$SHOW_SKILLS: "Show Skills", BUTTON$DISPLAY_COST: "Display Cost", COMMON$CLOSE_CONVERSATION_STOP_RUNTIME: - "Close Conversation (Stop Runtime)", + "Close Conversation (Stop Sandbox)", COMMON$DELETE_CONVERSATION: "Delete Conversation", CONVERSATION$SHARE_PUBLICLY: "Share Publicly", CONVERSATION$LINK_COPIED: "Link copied to clipboard", @@ -565,7 +565,7 @@ describe("ConversationNameContextMenu", () => { "Delete Conversation", ); expect(screen.getByTestId("stop-button")).toHaveTextContent( - "Close Conversation (Stop Runtime)", + "Close Conversation (Stop Sandbox)", ); expect(screen.getByTestId("display-cost-button")).toHaveTextContent( "Display Cost", diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index 17cbb24cdf..30fdeb9369 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -12,6 +12,7 @@ import type { V1AppConversationStartTask, V1AppConversationStartTaskPage, V1AppConversation, + V1AppConversationPage, GetSkillsResponse, V1RuntimeConversationInfo, } from "./v1-conversation-service.types"; @@ -424,6 +425,28 @@ class V1ConversationService { }); return data; } + + /** + * Search for V1 conversations by sandbox ID + * + * @param sandboxId The sandbox ID to filter by + * @param limit Maximum number of results (default: 100) + * @returns Array of conversations in the specified sandbox + */ + static async searchConversationsBySandboxId( + sandboxId: string, + limit: number = 100, + ): Promise { + const params = new URLSearchParams(); + params.append("sandbox_id__eq", sandboxId); + params.append("limit", limit.toString()); + + const { data } = await openHands.get( + `/api/v1/app-conversations/search?${params.toString()}`, + ); + + return data.items; + } } export default V1ConversationService; diff --git a/frontend/src/api/conversation-service/v1-conversation-service.types.ts b/frontend/src/api/conversation-service/v1-conversation-service.types.ts index fb59623372..b437e17bf1 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.types.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.types.ts @@ -119,6 +119,11 @@ export interface V1AppConversation { public?: boolean; } +export interface V1AppConversationPage { + items: V1AppConversation[]; + next_page_id: string | null; +} + export interface Skill { name: string; type: "repo" | "knowledge" | "agentskills"; diff --git a/frontend/src/components/features/conversation-panel/confirm-stop-modal.tsx b/frontend/src/components/features/conversation-panel/confirm-stop-modal.tsx index d841211ace..acf30f4b09 100644 --- a/frontend/src/components/features/conversation-panel/confirm-stop-modal.tsx +++ b/frontend/src/components/features/conversation-panel/confirm-stop-modal.tsx @@ -7,17 +7,71 @@ import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { ModalBody } from "#/components/shared/modals/modal-body"; import { BrandButton } from "../settings/brand-button"; import { I18nKey } from "#/i18n/declaration"; +import { useConversationsInSandbox } from "#/hooks/query/use-conversations-in-sandbox"; interface ConfirmStopModalProps { onConfirm: () => void; onCancel: () => void; + sandboxId: string | null; +} + +function ConversationsList({ + conversations, + isLoading, + isError, + t, +}: { + conversations: { id: string; title: string | null }[] | undefined; + isLoading: boolean; + isError: boolean; + t: (key: string) => string; +}) { + if (isLoading) { + return ( +
+ {t(I18nKey.HOME$LOADING)} +
+ ); + } + + if (isError) { + return ( +
+ {t(I18nKey.COMMON$ERROR)} +
+ ); + } + + if (conversations && conversations.length > 0) { + return ( +
    + {conversations.map((conv) => ( +
  • {conv.title || conv.id}
  • + ))} +
+ ); + } + + return null; } export function ConfirmStopModal({ onConfirm, onCancel, + sandboxId, }: ConfirmStopModalProps) { const { t } = useTranslation(); + const { + data: conversations, + isLoading, + isError, + } = useConversationsInSandbox(sandboxId); return ( @@ -29,6 +83,12 @@ export function ConfirmStopModal({ +
(null); const [selectedConversationVersion, setSelectedConversationVersion] = React.useState<"V0" | "V1" | undefined>(undefined); + const [selectedSandboxId, setSelectedSandboxId] = React.useState< + string | null + >(null); const [openContextMenuId, setOpenContextMenuId] = React.useState< string | null >(null); @@ -85,10 +88,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) { const handleStopConversation = ( conversationId: string, version?: "V0" | "V1", + sandboxId?: string | null, ) => { setConfirmStopModalVisible(true); setSelectedConversationId(conversationId); setSelectedConversationVersion(version); + setSelectedSandboxId(sandboxId ?? null); }; const handleConversationTitleChange = async ( @@ -185,6 +190,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) { handleStopConversation( project.conversation_id, project.conversation_version, + project.sandbox_id, ) } onChangeTitle={(title) => @@ -238,6 +244,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) { setConfirmStopModalVisible(false); }} onCancel={() => setConfirmStopModalVisible(false)} + sandboxId={selectedSandboxId} /> )} diff --git a/frontend/src/components/features/conversation/conversation-name.tsx b/frontend/src/components/features/conversation/conversation-name.tsx index b7a26aad30..664c583839 100644 --- a/frontend/src/components/features/conversation/conversation-name.tsx +++ b/frontend/src/components/features/conversation/conversation-name.tsx @@ -233,6 +233,7 @@ export function ConversationName() { setConfirmStopModalVisible(false)} + sandboxId={conversation?.sandbox_id ?? null} /> )} diff --git a/frontend/src/hooks/query/use-conversations-in-sandbox.ts b/frontend/src/hooks/query/use-conversations-in-sandbox.ts new file mode 100644 index 0000000000..f41edb7a54 --- /dev/null +++ b/frontend/src/hooks/query/use-conversations-in-sandbox.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; + +export const useConversationsInSandbox = (sandboxId: string | null) => + useQuery({ + queryKey: ["conversations", "sandbox", sandboxId], + queryFn: () => + sandboxId + ? V1ConversationService.searchConversationsBySandboxId(sandboxId) + : Promise.resolve([]), + enabled: !!sandboxId, + staleTime: 0, // Always consider data stale for confirmation dialogs + gcTime: 1000 * 60, // 1 minute + refetchOnMount: true, // Always fetch fresh data when modal opens + }); diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index d3f91ceec7..3437fde4a6 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -5856,36 +5856,36 @@ "uk": "Ви впевнені, що хочете призупинити цю розмову?" }, "CONVERSATION$CONFIRM_CLOSE_CONVERSATION": { - "en": "Confirm Close Conversation", - "ja": "会話終了の確認", - "zh-CN": "确认关闭对话", - "zh-TW": "確認關閉對話", - "ko-KR": "대화 종료 확인", - "no": "Bekreft avslutt samtale", - "it": "Conferma chiusura conversazione", - "pt": "Confirmar encerrar conversa", - "es": "Confirmar cerrar conversación", - "ar": "تأكيد إغلاق المحادثة", - "fr": "Confirmer la fermeture de la conversation", - "tr": "Konuşmayı Kapatmayı Onayla", - "de": "Gespräch schließen bestätigen", - "uk": "Підтвердити закриття розмови" + "en": "Confirm Stop Sandbox", + "ja": "サンドボックス停止の確認", + "zh-CN": "确认停止沙盒", + "zh-TW": "確認停止沙盒", + "ko-KR": "샌드박스 중지 확인", + "no": "Bekreft stopp sandkasse", + "it": "Conferma arresto sandbox", + "pt": "Confirmar parar sandbox", + "es": "Confirmar detener sandbox", + "ar": "تأكيد إيقاف صندوق الحماية", + "fr": "Confirmer l'arrêt du sandbox", + "tr": "Sandbox'ı Durdurmayı Onayla", + "de": "Sandbox-Stopp bestätigen", + "uk": "Підтвердити зупинку пісочниці" }, "CONVERSATION$CLOSE_CONVERSATION_WARNING": { - "en": "Are you sure you want to close this conversation and stop the runtime?", - "ja": "この会話を終了してランタイムを停止してもよろしいですか?", - "zh-CN": "您确定要关闭此对话并停止运行时吗?", - "zh-TW": "您確定要關閉此對話並停止執行時嗎?", - "ko-KR": "이 대화를 종료하고 런타임을 중지하시겠습니까?", - "no": "Er du sikker på at du vil avslutte denne samtalen og stoppe kjøretiden?", - "it": "Sei sicuro di voler chiudere questa conversazione e fermare il runtime?", - "pt": "Tem certeza de que deseja encerrar esta conversa e parar o runtime?", - "es": "¿Está seguro de que desea cerrar esta conversación y detener el runtime?", - "ar": "هل أنت متأكد أنك تريد إغلاق هذه المحادثة وإيقاف وقت التشغيل؟", - "fr": "Êtes-vous sûr de vouloir fermer cette conversation et arrêter le runtime ?", - "tr": "Bu konuşmayı kapatmak ve çalışma zamanını durdurmak istediğinizden emin misiniz?", - "de": "Sind Sie sicher, dass Sie dieses Gespräch schließen und die Laufzeit stoppen möchten?", - "uk": "Ви впевнені, що хочете закрити цю розмову та зупинити час виконання?" + "en": "This will stop the sandbox, and pause the following conversations:", + "ja": "サンドボックスを停止し、以下の会話を一時停止します:", + "zh-CN": "这将停止沙盒,并暂停以下对话:", + "zh-TW": "這將停止沙盒,並暫停以下對話:", + "ko-KR": "샌드박스를 중지하고 다음 대화를 일시 중지합니다:", + "no": "Dette vil stoppe sandkassen og pause følgende samtaler:", + "it": "Questo fermerà la sandbox e metterà in pausa le seguenti conversazioni:", + "pt": "Isso irá parar o sandbox e pausar as seguintes conversas:", + "es": "Esto detendrá el sandbox y pausará las siguientes conversaciones:", + "ar": "سيؤدي هذا إلى إيقاف صندوق الحماية وإيقاف المحادثات التالية مؤقتًا:", + "fr": "Cela arrêtera le sandbox et mettra en pause les conversations suivantes :", + "tr": "Bu, sandbox'ı durduracak ve aşağıdaki konuşmaları duraklatacaktır:", + "de": "Dies wird die Sandbox stoppen und die folgenden Gespräche pausieren:", + "uk": "Це зупинить пісочницю та призупинить наступні розмови:" }, "CONVERSATION$STOP_WARNING": { "en": "Are you sure you want to pause this conversation?", @@ -14964,20 +14964,20 @@ "uk": "Натисніть тут" }, "COMMON$CLOSE_CONVERSATION_STOP_RUNTIME": { - "en": "Close Conversation (Stop Runtime)", - "ja": "会話を閉じる(ランタイム停止)", - "zh-CN": "关闭对话(停止运行时)", - "zh-TW": "關閉對話(停止執行時)", - "ko-KR": "대화 닫기(런타임 중지)", - "no": "Lukk samtale (stopp kjøring)", - "it": "Chiudi conversazione (Interrompi runtime)", - "pt": "Fechar conversa (Parar execução)", - "es": "Cerrar conversación (Detener ejecución)", - "ar": "إغلاق المحادثة (إيقاف وقت التشغيل)", - "fr": "Fermer la conversation (Arrêter l'exécution)", - "tr": "Konuşmayı Kapat (Çalışma Zamanını Durdur)", - "de": "Gespräch schließen (Laufzeit beenden)", - "uk": "Закрити розмову (зупинити виконання)" + "en": "Close Conversation (Stop Sandbox)", + "ja": "会話を閉じる(サンドボックス停止)", + "zh-CN": "关闭对话(停止沙盒)", + "zh-TW": "關閉對話(停止沙盒)", + "ko-KR": "대화 닫기(샌드박스 중지)", + "no": "Lukk samtale (stopp sandkasse)", + "it": "Chiudi conversazione (Interrompi sandbox)", + "pt": "Fechar conversa (Parar sandbox)", + "es": "Cerrar conversación (Detener sandbox)", + "ar": "إغلاق المحادثة (إيقاف صندوق الحماية)", + "fr": "Fermer la conversation (Arrêter le sandbox)", + "tr": "Konuşmayı Kapat (Sandbox'ı Durdur)", + "de": "Gespräch schließen (Sandbox beenden)", + "uk": "Закрити розмову (зупинити пісочницю)" }, "COMMON$CODE": { "en": "Code", From 238cab4d08ebb176bf972e37ef55fa40615dbf82 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:25:44 +0700 Subject: [PATCH 31/92] fix(frontend): prevent chat message loss during websocket disconnections or page refresh (#13380) --- .../101_add_pending_messages_table.py | 39 ++ .../utils/saas_pending_message_injector.py | 172 +++++ .../components/interactive-chat-box.test.tsx | 4 +- .../conversation-local-storage.test.ts | 227 +++++++ .../conversation-websocket-handler.test.tsx | 241 ++++++- .../hooks/use-draft-persistence.test.tsx | 594 ++++++++++++++++++ .../hooks/use-handle-plan-click.test.tsx | 3 + .../pending-message-service.api.ts | 40 ++ .../pending-message-service.types.ts | 22 + .../features/chat/chat-interface.tsx | 10 +- .../features/chat/custom-chat-input.tsx | 2 + .../features/chat/interactive-chat-box.tsx | 3 +- .../conversation-websocket-context.tsx | 50 +- .../src/hooks/chat/use-chat-input-logic.ts | 11 + .../src/hooks/chat/use-draft-persistence.ts | 179 ++++++ frontend/src/hooks/use-send-message.ts | 21 +- .../src/utils/conversation-local-storage.ts | 4 + .../live_status_app_conversation_service.py | 99 +++ .../app_lifespan/alembic/versions/007.py | 39 ++ openhands/app_server/config.py | 26 + .../app_server/pending_messages/__init__.py | 21 + .../pending_message_models.py | 32 + .../pending_message_router.py | 104 +++ .../pending_message_service.py | 200 ++++++ openhands/app_server/v1_router.py | 4 + ...st_live_status_app_conversation_service.py | 4 + .../app_server/test_pending_message_router.py | 227 +++++++ .../test_pending_message_service.py | 309 +++++++++ .../server/data_models/test_conversation.py | 3 + 29 files changed, 2668 insertions(+), 22 deletions(-) create mode 100644 enterprise/migrations/versions/101_add_pending_messages_table.py create mode 100644 enterprise/server/utils/saas_pending_message_injector.py create mode 100644 frontend/__tests__/hooks/use-draft-persistence.test.tsx create mode 100644 frontend/src/api/pending-message-service/pending-message-service.api.ts create mode 100644 frontend/src/api/pending-message-service/pending-message-service.types.ts create mode 100644 frontend/src/hooks/chat/use-draft-persistence.ts create mode 100644 openhands/app_server/app_lifespan/alembic/versions/007.py create mode 100644 openhands/app_server/pending_messages/__init__.py create mode 100644 openhands/app_server/pending_messages/pending_message_models.py create mode 100644 openhands/app_server/pending_messages/pending_message_router.py create mode 100644 openhands/app_server/pending_messages/pending_message_service.py create mode 100644 tests/unit/app_server/test_pending_message_router.py create mode 100644 tests/unit/app_server/test_pending_message_service.py diff --git a/enterprise/migrations/versions/101_add_pending_messages_table.py b/enterprise/migrations/versions/101_add_pending_messages_table.py new file mode 100644 index 0000000000..cbe97a955b --- /dev/null +++ b/enterprise/migrations/versions/101_add_pending_messages_table.py @@ -0,0 +1,39 @@ +"""Add pending_messages table for server-side message queuing + +Revision ID: 101 +Revises: 100 +Create Date: 2025-03-15 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '101' +down_revision: Union[str, None] = '100' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create pending_messages table for storing messages before conversation is ready. + + Messages are stored temporarily until the conversation becomes ready, then + delivered and deleted regardless of success or failure. + """ + op.create_table( + 'pending_messages', + sa.Column('id', sa.String(), primary_key=True), + sa.Column('conversation_id', sa.String(), nullable=False, index=True), + sa.Column('role', sa.String(20), nullable=False, server_default='user'), + sa.Column('content', sa.JSON, nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + ) + + +def downgrade() -> None: + """Remove pending_messages table.""" + op.drop_table('pending_messages') diff --git a/enterprise/server/utils/saas_pending_message_injector.py b/enterprise/server/utils/saas_pending_message_injector.py new file mode 100644 index 0000000000..fa47152801 --- /dev/null +++ b/enterprise/server/utils/saas_pending_message_injector.py @@ -0,0 +1,172 @@ +"""Enterprise injector for PendingMessageService with SAAS filtering.""" + +from typing import AsyncGenerator +from uuid import UUID + +from fastapi import Request +from sqlalchemy import select +from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas +from storage.user import User + +from openhands.agent_server.models import ImageContent, TextContent +from openhands.app_server.errors import AuthError +from openhands.app_server.pending_messages.pending_message_models import ( + PendingMessageResponse, +) +from openhands.app_server.pending_messages.pending_message_service import ( + PendingMessageService, + PendingMessageServiceInjector, + SQLPendingMessageService, +) +from openhands.app_server.services.injector import InjectorState +from openhands.app_server.user.specifiy_user_context import ADMIN +from openhands.app_server.user.user_context import UserContext + + +class SaasSQLPendingMessageService(SQLPendingMessageService): + """Extended SQLPendingMessageService with user and organization-based filtering. + + This enterprise version ensures that: + - Users can only queue messages for conversations they own + - Organization isolation is enforced for multi-tenant deployments + """ + + def __init__(self, db_session, user_context: UserContext): + super().__init__(db_session=db_session) + self.user_context = user_context + + async def _get_current_user(self) -> User | None: + """Get the current user using the existing db_session. + + Returns: + User object or None if no user_id is available + """ + user_id_str = await self.user_context.get_user_id() + if not user_id_str: + return None + + user_id_uuid = UUID(user_id_str) + result = await self.db_session.execute( + select(User).where(User.id == user_id_uuid) + ) + return result.scalars().first() + + async def _validate_conversation_ownership(self, conversation_id: str) -> None: + """Validate that the current user owns the conversation. + + This ensures multi-tenant isolation by checking: + - The conversation belongs to the current user + - The conversation belongs to the user's current organization + + Args: + conversation_id: The conversation ID to validate (can be task-id or UUID) + + Raises: + AuthError: If user doesn't own the conversation or authentication fails + """ + # For internal operations (e.g., processing pending messages during startup) + # we need a mode that bypasses filtering. The ADMIN context enables this. + if self.user_context == ADMIN: + return + + user_id_str = await self.user_context.get_user_id() + if not user_id_str: + raise AuthError('User authentication required') + + user_id_uuid = UUID(user_id_str) + + # Check conversation ownership via SAAS metadata + query = select(StoredConversationMetadataSaas).where( + StoredConversationMetadataSaas.conversation_id == conversation_id + ) + result = await self.db_session.execute(query) + saas_metadata = result.scalar_one_or_none() + + # If no SAAS metadata exists, the conversation might be a new task-id + # that hasn't been linked to a conversation yet. Allow access in this case + # as the message will be validated when the conversation is created. + if saas_metadata is None: + return + + # Verify user ownership + if saas_metadata.user_id != user_id_uuid: + raise AuthError('You do not have access to this conversation') + + # Verify organization ownership if applicable + user = await self._get_current_user() + if user and user.current_org_id is not None: + if saas_metadata.org_id != user.current_org_id: + raise AuthError('Conversation belongs to a different organization') + + async def add_message( + self, + conversation_id: str, + content: list[TextContent | ImageContent], + role: str = 'user', + ) -> PendingMessageResponse: + """Queue a message with ownership validation. + + Args: + conversation_id: The conversation ID to queue the message for + content: Message content + role: Message role (default: 'user') + + Returns: + PendingMessageResponse with the queued message info + + Raises: + AuthError: If user doesn't own the conversation + """ + await self._validate_conversation_ownership(conversation_id) + return await super().add_message(conversation_id, content, role) + + async def get_pending_messages(self, conversation_id: str): + """Get pending messages with ownership validation. + + Args: + conversation_id: The conversation ID to get messages for + + Returns: + List of pending messages + + Raises: + AuthError: If user doesn't own the conversation + """ + await self._validate_conversation_ownership(conversation_id) + return await super().get_pending_messages(conversation_id) + + async def count_pending_messages(self, conversation_id: str) -> int: + """Count pending messages with ownership validation. + + Args: + conversation_id: The conversation ID to count messages for + + Returns: + Number of pending messages + + Raises: + AuthError: If user doesn't own the conversation + """ + await self._validate_conversation_ownership(conversation_id) + return await super().count_pending_messages(conversation_id) + + +class SaasPendingMessageServiceInjector(PendingMessageServiceInjector): + """Enterprise injector for PendingMessageService with SAAS filtering.""" + + async def inject( + self, state: InjectorState, request: Request | None = None + ) -> AsyncGenerator[PendingMessageService, None]: + from openhands.app_server.config import ( + get_db_session, + get_user_context, + ) + + async with ( + get_user_context(state, request) as user_context, + get_db_session(state, request) as db_session, + ): + service = SaasSQLPendingMessageService( + db_session=db_session, user_context=user_context + ) + yield service diff --git a/frontend/__tests__/components/interactive-chat-box.test.tsx b/frontend/__tests__/components/interactive-chat-box.test.tsx index cb164123c1..bafa673731 100644 --- a/frontend/__tests__/components/interactive-chat-box.test.tsx +++ b/frontend/__tests__/components/interactive-chat-box.test.tsx @@ -198,9 +198,9 @@ describe("InteractiveChatBox", () => { expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [], []); }); - it("should disable the submit button when agent is loading", async () => { + it("should disable the submit button when awaiting user confirmation", async () => { const user = userEvent.setup(); - mockStores(AgentState.LOADING); + mockStores(AgentState.AWAITING_USER_CONFIRMATION); renderInteractiveChatBox({ onSubmit: onSubmitMock, diff --git a/frontend/__tests__/conversation-local-storage.test.ts b/frontend/__tests__/conversation-local-storage.test.ts index a99e5fc005..33e9e12a7e 100644 --- a/frontend/__tests__/conversation-local-storage.test.ts +++ b/frontend/__tests__/conversation-local-storage.test.ts @@ -229,4 +229,231 @@ describe("conversation localStorage utilities", () => { expect(parsed.subConversationTaskId).toBeNull(); }); }); + + describe("draftMessage persistence", () => { + describe("getConversationState", () => { + it("returns default draftMessage as null when no state exists", () => { + // Arrange + const conversationId = "conv-draft-1"; + + // Act + const state = getConversationState(conversationId); + + // Assert + expect(state.draftMessage).toBeNull(); + }); + + it("retrieves draftMessage from localStorage when it exists", () => { + // Arrange + const conversationId = "conv-draft-2"; + const draftText = "This is my saved draft message"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + draftMessage: draftText, + }), + ); + + // Act + const state = getConversationState(conversationId); + + // Assert + expect(state.draftMessage).toBe(draftText); + }); + + it("returns null draftMessage for task conversation IDs (not persisted)", () => { + // Arrange + const taskId = "task-uuid-123"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`; + + // Even if somehow there's data in localStorage for a task ID + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + draftMessage: "Should not be returned", + }), + ); + + // Act + const state = getConversationState(taskId); + + // Assert - should return default state, not the stored value + expect(state.draftMessage).toBeNull(); + }); + }); + + describe("setConversationState", () => { + it("persists draftMessage to localStorage", () => { + // Arrange + const conversationId = "conv-draft-3"; + const draftText = "New draft message to save"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + // Act + setConversationState(conversationId, { + draftMessage: draftText, + }); + + // Assert + const stored = localStorage.getItem(consolidatedKey); + expect(stored).not.toBeNull(); + const parsed = JSON.parse(stored!); + expect(parsed.draftMessage).toBe(draftText); + }); + + it("does not persist draftMessage for task conversation IDs", () => { + // Arrange + const taskId = "task-draft-xyz"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`; + + // Act + setConversationState(taskId, { + draftMessage: "Draft for task ID", + }); + + // Assert - nothing should be stored + expect(localStorage.getItem(consolidatedKey)).toBeNull(); + }); + + it("merges draftMessage with existing state without overwriting other fields", () => { + // Arrange + const conversationId = "conv-draft-4"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + selectedTab: "terminal", + rightPanelShown: false, + unpinnedTabs: ["tab-1", "tab-2"], + conversationMode: "plan", + subConversationTaskId: "task-123", + }), + ); + + // Act + setConversationState(conversationId, { + draftMessage: "Updated draft", + }); + + // Assert + const stored = localStorage.getItem(consolidatedKey); + const parsed = JSON.parse(stored!); + + expect(parsed.draftMessage).toBe("Updated draft"); + expect(parsed.selectedTab).toBe("terminal"); + expect(parsed.rightPanelShown).toBe(false); + expect(parsed.unpinnedTabs).toEqual(["tab-1", "tab-2"]); + expect(parsed.conversationMode).toBe("plan"); + expect(parsed.subConversationTaskId).toBe("task-123"); + }); + + it("clears draftMessage when set to null", () => { + // Arrange + const conversationId = "conv-draft-5"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + draftMessage: "Existing draft", + }), + ); + + // Act + setConversationState(conversationId, { + draftMessage: null, + }); + + // Assert + const stored = localStorage.getItem(consolidatedKey); + const parsed = JSON.parse(stored!); + expect(parsed.draftMessage).toBeNull(); + }); + + it("clears draftMessage when set to empty string (stored as empty string)", () => { + // Arrange + const conversationId = "conv-draft-6"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + draftMessage: "Existing draft", + }), + ); + + // Act + setConversationState(conversationId, { + draftMessage: "", + }); + + // Assert + const stored = localStorage.getItem(consolidatedKey); + const parsed = JSON.parse(stored!); + expect(parsed.draftMessage).toBe(""); + }); + }); + + describe("conversation-specific draft isolation", () => { + it("stores drafts separately for different conversations", () => { + // Arrange + const convA = "conv-A"; + const convB = "conv-B"; + const draftA = "Draft for conversation A"; + const draftB = "Draft for conversation B"; + + // Act + setConversationState(convA, { draftMessage: draftA }); + setConversationState(convB, { draftMessage: draftB }); + + // Assert + const stateA = getConversationState(convA); + const stateB = getConversationState(convB); + + expect(stateA.draftMessage).toBe(draftA); + expect(stateB.draftMessage).toBe(draftB); + }); + + it("updating one conversation draft does not affect another", () => { + // Arrange + const convA = "conv-isolated-A"; + const convB = "conv-isolated-B"; + + setConversationState(convA, { draftMessage: "Original draft A" }); + setConversationState(convB, { draftMessage: "Original draft B" }); + + // Act - update only conversation A + setConversationState(convA, { draftMessage: "Updated draft A" }); + + // Assert - conversation B should be unchanged + const stateA = getConversationState(convA); + const stateB = getConversationState(convB); + + expect(stateA.draftMessage).toBe("Updated draft A"); + expect(stateB.draftMessage).toBe("Original draft B"); + }); + + it("clearing one conversation draft does not affect another", () => { + // Arrange + const convA = "conv-clear-A"; + const convB = "conv-clear-B"; + + setConversationState(convA, { draftMessage: "Draft A" }); + setConversationState(convB, { draftMessage: "Draft B" }); + + // Act - clear draft for conversation A + setConversationState(convA, { draftMessage: null }); + + // Assert + const stateA = getConversationState(convA); + const stateB = getConversationState(convB); + + expect(stateA.draftMessage).toBeNull(); + expect(stateB.draftMessage).toBe("Draft B"); + }); + }); + }); }); diff --git a/frontend/__tests__/conversation-websocket-handler.test.tsx b/frontend/__tests__/conversation-websocket-handler.test.tsx index 284aaee287..393d6f68f0 100644 --- a/frontend/__tests__/conversation-websocket-handler.test.tsx +++ b/frontend/__tests__/conversation-websocket-handler.test.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { describe, it, @@ -8,7 +9,7 @@ import { afterEach, vi, } from "vitest"; -import { screen, waitFor, render, cleanup } from "@testing-library/react"; +import { screen, waitFor, render, cleanup, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { http, HttpResponse } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; @@ -682,8 +683,242 @@ describe("Conversation WebSocket Handler", () => { // 7. Message Sending Tests describe("Message Sending", () => { - it.todo("should send user actions through WebSocket when connected"); - it.todo("should handle send attempts when disconnected"); + it("should send user actions through WebSocket when connected", async () => { + // Arrange + const conversationId = "test-conversation-send"; + let receivedMessage: unknown = null; + + // Set up MSW to capture sent messages + mswServer.use( + wsLink.addEventListener("connection", ({ client, server }) => { + server.connect(); + + // Capture messages sent from client + client.addEventListener("message", (event) => { + receivedMessage = JSON.parse(event.data as string); + }); + }), + ); + + // Create ref to store sendMessage function + let sendMessageFn: typeof useConversationWebSocket extends () => infer R + ? R extends { sendMessage: infer S } + ? S + : null + : null = null; + + function TestComponent() { + const context = useConversationWebSocket(); + + React.useEffect(() => { + if (context?.sendMessage) { + sendMessageFn = context.sendMessage; + } + }, [context?.sendMessage]); + + return ( +
+
+ {context?.connectionState || "NOT_AVAILABLE"} +
+
+ ); + } + + // Act + renderWithWebSocketContext( + , + conversationId, + `http://localhost:3000/api/conversations/${conversationId}`, + ); + + // Wait for connection + await waitFor(() => { + expect(screen.getByTestId("connection-state")).toHaveTextContent( + "OPEN", + ); + }); + + // Send a message + await waitFor(() => { + expect(sendMessageFn).not.toBeNull(); + }); + + await act(async () => { + await sendMessageFn!({ + role: "user", + content: [{ type: "text", text: "Hello from test" }], + }); + }); + + // Assert - message should have been received by mock server + await waitFor(() => { + expect(receivedMessage).toEqual({ + role: "user", + content: [{ type: "text", text: "Hello from test" }], + }); + }); + }); + + it("should not throw error when sendMessage is called with WebSocket connected", async () => { + // This test verifies that sendMessage doesn't throw an error + // when the WebSocket is connected. + const conversationId = "test-conversation-no-throw"; + let sendError: Error | null = null; + + // Set up MSW to connect and receive messages + mswServer.use( + wsLink.addEventListener("connection", ({ server }) => { + server.connect(); + }), + ); + + // Create ref to store sendMessage function + let sendMessageFn: typeof useConversationWebSocket extends () => infer R + ? R extends { sendMessage: infer S } + ? S + : null + : null = null; + + function TestComponent() { + const context = useConversationWebSocket(); + + React.useEffect(() => { + if (context?.sendMessage) { + sendMessageFn = context.sendMessage; + } + }, [context?.sendMessage]); + + return ( +
+
+ {context?.connectionState || "NOT_AVAILABLE"} +
+
+ ); + } + + // Act + renderWithWebSocketContext( + , + conversationId, + `http://localhost:3000/api/conversations/${conversationId}`, + ); + + // Wait for connection + await waitFor(() => { + expect(screen.getByTestId("connection-state")).toHaveTextContent( + "OPEN", + ); + }); + + // Wait for the context to be available + await waitFor(() => { + expect(sendMessageFn).not.toBeNull(); + }); + + // Try to send a message + await act(async () => { + try { + await sendMessageFn!({ + role: "user", + content: [{ type: "text", text: "Test message" }], + }); + } catch (error) { + sendError = error as Error; + } + }); + + // Assert - should NOT throw an error + expect(sendError).toBeNull(); + }); + + it("should send multiple messages through WebSocket in order", async () => { + // Arrange + const conversationId = "test-conversation-multi"; + const receivedMessages: unknown[] = []; + + // Set up MSW to capture sent messages + mswServer.use( + wsLink.addEventListener("connection", ({ client, server }) => { + server.connect(); + + // Capture messages sent from client + client.addEventListener("message", (event) => { + receivedMessages.push(JSON.parse(event.data as string)); + }); + }), + ); + + // Create ref to store sendMessage function + let sendMessageFn: typeof useConversationWebSocket extends () => infer R + ? R extends { sendMessage: infer S } + ? S + : null + : null = null; + + function TestComponent() { + const context = useConversationWebSocket(); + + React.useEffect(() => { + if (context?.sendMessage) { + sendMessageFn = context.sendMessage; + } + }, [context?.sendMessage]); + + return ( +
+
+ {context?.connectionState || "NOT_AVAILABLE"} +
+
+ ); + } + + // Act + renderWithWebSocketContext( + , + conversationId, + `http://localhost:3000/api/conversations/${conversationId}`, + ); + + // Wait for connection + await waitFor(() => { + expect(screen.getByTestId("connection-state")).toHaveTextContent( + "OPEN", + ); + }); + + await waitFor(() => { + expect(sendMessageFn).not.toBeNull(); + }); + + // Send multiple messages + await act(async () => { + await sendMessageFn!({ + role: "user", + content: [{ type: "text", text: "Message 1" }], + }); + await sendMessageFn!({ + role: "user", + content: [{ type: "text", text: "Message 2" }], + }); + }); + + // Assert - both messages should have been received in order + await waitFor(() => { + expect(receivedMessages.length).toBe(2); + }); + + expect(receivedMessages[0]).toEqual({ + role: "user", + content: [{ type: "text", text: "Message 1" }], + }); + expect(receivedMessages[1]).toEqual({ + role: "user", + content: [{ type: "text", text: "Message 2" }], + }); + }); }); // 8. History Loading State Tests diff --git a/frontend/__tests__/hooks/use-draft-persistence.test.tsx b/frontend/__tests__/hooks/use-draft-persistence.test.tsx new file mode 100644 index 0000000000..0734470324 --- /dev/null +++ b/frontend/__tests__/hooks/use-draft-persistence.test.tsx @@ -0,0 +1,594 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useDraftPersistence } from "#/hooks/chat/use-draft-persistence"; +import * as conversationLocalStorage from "#/utils/conversation-local-storage"; + +// Mock the entire module +vi.mock("#/utils/conversation-local-storage", () => ({ + useConversationLocalStorageState: vi.fn(), + getConversationState: vi.fn(), + setConversationState: vi.fn(), +})); + +// Mock the getTextContent utility +vi.mock("#/components/features/chat/utils/chat-input.utils", () => ({ + getTextContent: vi.fn((el: HTMLDivElement | null) => el?.textContent || ""), +})); + +describe("useDraftPersistence", () => { + let mockSetDraftMessage: (message: string | null) => void; + + // Create a mock ref to contentEditable div + const createMockChatInputRef = (initialContent = "") => { + const div = document.createElement("div"); + div.setAttribute("contenteditable", "true"); + div.textContent = initialContent; + return { current: div }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + localStorage.clear(); + + mockSetDraftMessage = vi.fn<(message: string | null) => void>(); + + // Default mock for useConversationLocalStorageState + vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({ + state: { + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: null, + }, + setSelectedTab: vi.fn(), + setRightPanelShown: vi.fn(), + setUnpinnedTabs: vi.fn(), + setConversationMode: vi.fn(), + setDraftMessage: mockSetDraftMessage, + }); + + // Default mock for getConversationState + vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: null, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe("draft restoration on mount", () => { + it("restores draft from localStorage when mounting with existing draft", () => { + // Arrange + const conversationId = "conv-restore-1"; + const savedDraft = "Previously saved draft message"; + const chatInputRef = createMockChatInputRef(); + + vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: savedDraft, + }); + + // Act + renderHook(() => useDraftPersistence(conversationId, chatInputRef)); + + // Assert - draft should be restored to the DOM element + expect(chatInputRef.current?.textContent).toBe(savedDraft); + }); + + it("clears input on mount then restores draft if exists", () => { + // Arrange + const conversationId = "conv-restore-2"; + const existingContent = "Stale content from previous conversation"; + const savedDraft = "Saved draft"; + const chatInputRef = createMockChatInputRef(existingContent); + + vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: savedDraft, + }); + + // Act + renderHook(() => useDraftPersistence(conversationId, chatInputRef)); + + // Assert - input cleared then draft restored + expect(chatInputRef.current?.textContent).toBe(savedDraft); + }); + + it("clears input when no draft exists for conversation", () => { + // Arrange + const conversationId = "conv-no-draft"; + const chatInputRef = createMockChatInputRef("Some stale content"); + + vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: null, + }); + + // Act + renderHook(() => useDraftPersistence(conversationId, chatInputRef)); + + // Assert - content should be cleared since there's no draft + expect(chatInputRef.current?.textContent).toBe(""); + }); + }); + + describe("debounced saving", () => { + it("saves draft after debounce period", () => { + // Arrange + const conversationId = "conv-debounce-1"; + const chatInputRef = createMockChatInputRef(); + + const { result } = renderHook(() => + useDraftPersistence(conversationId, chatInputRef), + ); + + // Act - simulate user typing + chatInputRef.current!.textContent = "New draft content"; + act(() => { + result.current.saveDraft(); + }); + + // Assert - should not save immediately + expect(mockSetDraftMessage).not.toHaveBeenCalled(); + + // Fast forward past debounce period (500ms) + act(() => { + vi.advanceTimersByTime(500); + }); + + // Assert - should save after debounce + expect(mockSetDraftMessage).toHaveBeenCalledWith("New draft content"); + }); + + it("cancels pending save when new input arrives before debounce", () => { + // Arrange + const conversationId = "conv-debounce-2"; + const chatInputRef = createMockChatInputRef(); + + const { result } = renderHook(() => + useDraftPersistence(conversationId, chatInputRef), + ); + + // Act - first input + chatInputRef.current!.textContent = "First"; + act(() => { + result.current.saveDraft(); + }); + + // Wait 200ms (less than debounce) + act(() => { + vi.advanceTimersByTime(200); + }); + + // Second input before debounce completes + chatInputRef.current!.textContent = "First Second"; + act(() => { + result.current.saveDraft(); + }); + + // Complete the second debounce + act(() => { + vi.advanceTimersByTime(500); + }); + + // Assert - should only save the final value once + expect(mockSetDraftMessage).toHaveBeenCalledTimes(1); + expect(mockSetDraftMessage).toHaveBeenCalledWith("First Second"); + }); + + it("does not save if content matches existing draft", () => { + // Arrange + const conversationId = "conv-no-change"; + const existingDraft = "Existing draft"; + const chatInputRef = createMockChatInputRef(existingDraft); + + vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({ + state: { + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: existingDraft, + }, + setSelectedTab: vi.fn(), + setRightPanelShown: vi.fn(), + setUnpinnedTabs: vi.fn(), + setConversationMode: vi.fn(), + setDraftMessage: mockSetDraftMessage, + }); + + vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: existingDraft, + }); + + const { result } = renderHook(() => + useDraftPersistence(conversationId, chatInputRef), + ); + + // Act - try to save same content + act(() => { + result.current.saveDraft(); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + // Assert - should not save since content is the same + expect(mockSetDraftMessage).not.toHaveBeenCalled(); + }); + }); + + describe("clearDraft", () => { + it("clears the draft from localStorage", () => { + // Arrange + const conversationId = "conv-clear-1"; + const chatInputRef = createMockChatInputRef("Some content"); + + const { result } = renderHook(() => + useDraftPersistence(conversationId, chatInputRef), + ); + + // Act + act(() => { + result.current.clearDraft(); + }); + + // Assert + expect(mockSetDraftMessage).toHaveBeenCalledWith(null); + }); + + it("cancels any pending debounced save when clearing", () => { + // Arrange + const conversationId = "conv-clear-2"; + const chatInputRef = createMockChatInputRef(); + + const { result } = renderHook(() => + useDraftPersistence(conversationId, chatInputRef), + ); + + // Start a save + chatInputRef.current!.textContent = "Pending draft"; + act(() => { + result.current.saveDraft(); + }); + + // Clear before debounce completes + act(() => { + vi.advanceTimersByTime(200); + result.current.clearDraft(); + }); + + // Complete the original debounce period + act(() => { + vi.advanceTimersByTime(500); + }); + + // Assert - only the clear should have been called (the pending save should be cancelled) + expect(mockSetDraftMessage).toHaveBeenCalledTimes(1); + expect(mockSetDraftMessage).toHaveBeenCalledWith(null); + }); + }); + + describe("conversation switching", () => { + it("clears input when switching to a new conversation without a draft", () => { + // Arrange + const chatInputRef = createMockChatInputRef("Draft from conv A"); + + // First conversation has a draft + vi.mocked(conversationLocalStorage.getConversationState) + .mockReturnValueOnce({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: "Draft from conv A", + }) + .mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: null, + }); + + const { rerender } = renderHook( + ({ conversationId }) => + useDraftPersistence(conversationId, chatInputRef), + { initialProps: { conversationId: "conv-A" } }, + ); + + // Act - switch to conversation B + rerender({ conversationId: "conv-B" }); + + // Assert - input should be cleared (no draft for conv-B) + expect(chatInputRef.current?.textContent).toBe(""); + }); + + it("restores draft when switching to a conversation with an existing draft", () => { + // Arrange + const chatInputRef = createMockChatInputRef(); + const draftForConvB = "Saved draft for conversation B"; + + vi.mocked(conversationLocalStorage.getConversationState) + .mockReturnValueOnce({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: null, + }) + .mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: draftForConvB, + }); + + const { rerender } = renderHook( + ({ conversationId }) => + useDraftPersistence(conversationId, chatInputRef), + { initialProps: { conversationId: "conv-A" } }, + ); + + // Act - switch to conversation B + rerender({ conversationId: "conv-B" }); + + // Assert - draft for conv-B should be restored + expect(chatInputRef.current?.textContent).toBe(draftForConvB); + }); + + it("cancels pending save when switching conversations", () => { + // Arrange + const chatInputRef = createMockChatInputRef(); + + const { result, rerender } = renderHook( + ({ conversationId }) => + useDraftPersistence(conversationId, chatInputRef), + { initialProps: { conversationId: "conv-A" } }, + ); + + // Start typing in conv-A + chatInputRef.current!.textContent = "Draft for conv-A"; + act(() => { + result.current.saveDraft(); + }); + + // Switch conversation before debounce completes + act(() => { + vi.advanceTimersByTime(200); + }); + rerender({ conversationId: "conv-B" }); + + // Complete the debounce period + act(() => { + vi.advanceTimersByTime(500); + }); + + // Assert - the save should NOT have happened because conversation changed + expect(mockSetDraftMessage).not.toHaveBeenCalled(); + }); + }); + + describe("task ID to real conversation ID transition", () => { + it("transfers draft from task ID to real conversation ID during transition", () => { + // Arrange + const chatInputRef = createMockChatInputRef("Draft typed during init"); + + vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: null, + }); + + const { rerender } = renderHook( + ({ conversationId }) => + useDraftPersistence(conversationId, chatInputRef), + { initialProps: { conversationId: "task-abc-123" } }, + ); + + // Simulate user typing during task initialization + chatInputRef.current!.textContent = "Draft typed during init"; + + // Act - transition to real conversation ID + rerender({ conversationId: "conv-real-123" }); + + // Assert - draft should be saved to the new real conversation ID + expect(conversationLocalStorage.setConversationState).toHaveBeenCalledWith( + "conv-real-123", + { draftMessage: "Draft typed during init" }, + ); + + // And the draft should remain visible in the input + expect(chatInputRef.current?.textContent).toBe("Draft typed during init"); + }); + + it("does not transfer empty draft during task-to-real transition", () => { + // Arrange + const chatInputRef = createMockChatInputRef(""); + + vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: null, + }); + + const { rerender } = renderHook( + ({ conversationId }) => + useDraftPersistence(conversationId, chatInputRef), + { initialProps: { conversationId: "task-abc-123" } }, + ); + + // Act - transition to real conversation ID with empty input + rerender({ conversationId: "conv-real-123" }); + + // Assert - no draft should be saved (input is cleared, checked by hook) + // The setConversationState should not be called with draftMessage + expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled(); + }); + + it("does not transfer draft for non-task ID transitions", () => { + // Arrange + const chatInputRef = createMockChatInputRef("Some draft"); + + vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: null, + }); + + const { rerender } = renderHook( + ({ conversationId }) => + useDraftPersistence(conversationId, chatInputRef), + { initialProps: { conversationId: "conv-A" } }, + ); + + // Act - normal conversation switch (not task-to-real) + rerender({ conversationId: "conv-B" }); + + // Assert - should not use setConversationState directly + // (the normal path uses setDraftMessage from the hook) + expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled(); + }); + }); + + describe("hasDraft and isRestored state", () => { + it("returns hasDraft true when draft exists in hook state", () => { + // Arrange + const conversationId = "conv-has-draft"; + const chatInputRef = createMockChatInputRef(); + + vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({ + state: { + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: "Existing draft", + }, + setSelectedTab: vi.fn(), + setRightPanelShown: vi.fn(), + setUnpinnedTabs: vi.fn(), + setConversationMode: vi.fn(), + setDraftMessage: mockSetDraftMessage, + }); + + // Act + const { result } = renderHook(() => + useDraftPersistence(conversationId, chatInputRef), + ); + + // Assert + expect(result.current.hasDraft).toBe(true); + }); + + it("returns hasDraft false when no draft exists", () => { + // Arrange + const conversationId = "conv-no-draft"; + const chatInputRef = createMockChatInputRef(); + + // Act + const { result } = renderHook(() => + useDraftPersistence(conversationId, chatInputRef), + ); + + // Assert + expect(result.current.hasDraft).toBe(false); + }); + + it("sets isRestored to true after restoration completes", () => { + // Arrange + const conversationId = "conv-restored"; + const chatInputRef = createMockChatInputRef(); + + vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: "Draft to restore", + }); + + // Act + const { result } = renderHook(() => + useDraftPersistence(conversationId, chatInputRef), + ); + + // Assert + expect(result.current.isRestored).toBe(true); + }); + }); + + describe("cleanup on unmount", () => { + it("clears pending timeout on unmount", () => { + // Arrange + const conversationId = "conv-unmount"; + const chatInputRef = createMockChatInputRef(); + + const { result, unmount } = renderHook(() => + useDraftPersistence(conversationId, chatInputRef), + ); + + // Start a save + chatInputRef.current!.textContent = "Draft"; + act(() => { + result.current.saveDraft(); + }); + + // Unmount before debounce completes + unmount(); + + // Complete the debounce period + act(() => { + vi.advanceTimersByTime(500); + }); + + // Assert - save should not have been called after unmount + expect(mockSetDraftMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/__tests__/hooks/use-handle-plan-click.test.tsx b/frontend/__tests__/hooks/use-handle-plan-click.test.tsx index 067a208c81..fdaa4c06aa 100644 --- a/frontend/__tests__/hooks/use-handle-plan-click.test.tsx +++ b/frontend/__tests__/hooks/use-handle-plan-click.test.tsx @@ -88,6 +88,7 @@ describe("useHandlePlanClick", () => { unpinnedTabs: [], subConversationTaskId: null, conversationMode: "code", + draftMessage: null, }); }); @@ -117,6 +118,7 @@ describe("useHandlePlanClick", () => { unpinnedTabs: [], subConversationTaskId: storedTaskId, conversationMode: "code", + draftMessage: null, }); renderHook(() => useHandlePlanClick()); @@ -155,6 +157,7 @@ describe("useHandlePlanClick", () => { unpinnedTabs: [], subConversationTaskId: storedTaskId, conversationMode: "code", + draftMessage: null, }); renderHook(() => useHandlePlanClick()); diff --git a/frontend/src/api/pending-message-service/pending-message-service.api.ts b/frontend/src/api/pending-message-service/pending-message-service.api.ts new file mode 100644 index 0000000000..8c7ef73a8a --- /dev/null +++ b/frontend/src/api/pending-message-service/pending-message-service.api.ts @@ -0,0 +1,40 @@ +/** + * Pending Message Service + * + * This service handles server-side message queuing for V1 conversations. + * Messages can be queued when the WebSocket is not connected and will be + * delivered automatically when the conversation becomes ready. + */ + +import { openHands } from "../open-hands-axios"; +import type { + PendingMessageResponse, + QueuePendingMessageRequest, +} from "./pending-message-service.types"; + +class PendingMessageService { + /** + * Queue a message for delivery when conversation becomes ready. + * + * This endpoint allows users to submit messages even when the conversation's + * WebSocket connection is not yet established. Messages are stored server-side + * and delivered automatically when the conversation transitions to READY status. + * + * @param conversationId The conversation ID (can be task ID before conversation is ready) + * @param message The message to queue + * @returns PendingMessageResponse with the message ID and queue position + * @throws Error if too many pending messages (limit: 10 per conversation) + */ + static async queueMessage( + conversationId: string, + message: QueuePendingMessageRequest, + ): Promise { + const { data } = await openHands.post( + `/api/v1/conversations/${conversationId}/pending-messages`, + message, + ); + return data; + } +} + +export default PendingMessageService; diff --git a/frontend/src/api/pending-message-service/pending-message-service.types.ts b/frontend/src/api/pending-message-service/pending-message-service.types.ts new file mode 100644 index 0000000000..cf7b8dbf0e --- /dev/null +++ b/frontend/src/api/pending-message-service/pending-message-service.types.ts @@ -0,0 +1,22 @@ +/** + * Types for the pending message service + */ + +import type { V1MessageContent } from "../conversation-service/v1-conversation-service.types"; + +/** + * Response when queueing a pending message + */ +export interface PendingMessageResponse { + id: string; + queued: boolean; + position: number; +} + +/** + * Request to queue a pending message + */ +export interface QueuePendingMessageRequest { + role?: "user"; + content: V1MessageContent[]; +} diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index 85a9435678..43218149ae 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -190,8 +190,14 @@ export function ChatInterface() { const prompt = uploadedFiles.length > 0 ? `${content}\n\n${filePrompt}` : content; - send(createChatMessage(prompt, imageUrls, uploadedFiles, timestamp)); - setOptimisticUserMessage(content); + const result = await send( + createChatMessage(prompt, imageUrls, uploadedFiles, timestamp), + ); + // Only show optimistic UI if message was sent immediately via WebSocket + // If queued for later delivery, the message will appear when actually delivered + if (!result.queued) { + setOptimisticUserMessage(content); + } setMessageToSend(""); }; diff --git a/frontend/src/components/features/chat/custom-chat-input.tsx b/frontend/src/components/features/chat/custom-chat-input.tsx index 5fd92fdcd6..26a0f74ca9 100644 --- a/frontend/src/components/features/chat/custom-chat-input.tsx +++ b/frontend/src/components/features/chat/custom-chat-input.tsx @@ -60,6 +60,7 @@ export function CustomChatInput({ messageToSend, checkIsContentEmpty, clearEmptyContentHandler, + saveDraft, } = useChatInputLogic(); const { @@ -158,6 +159,7 @@ export function CustomChatInput({ onInput={() => { handleInput(); updateSlashMenu(); + saveDraft(); }} onPaste={handlePaste} onKeyDown={(e) => { diff --git a/frontend/src/components/features/chat/interactive-chat-box.tsx b/frontend/src/components/features/chat/interactive-chat-box.tsx index a2f1df8348..74818d1d6c 100644 --- a/frontend/src/components/features/chat/interactive-chat-box.tsx +++ b/frontend/src/components/features/chat/interactive-chat-box.tsx @@ -142,8 +142,9 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) { handleSubmit(suggestion); }; + // Allow users to submit messages during LOADING state - they will be + // queued server-side and delivered when the conversation becomes ready const isDisabled = - curAgentState === AgentState.LOADING || curAgentState === AgentState.AWAITING_USER_CONFIRMATION || isTaskPolling(subConversationTaskStatus); diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index 572ab4fd75..86863734b9 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -40,6 +40,7 @@ import type { V1SendMessageRequest, } from "#/api/conversation-service/v1-conversation-service.types"; import EventService from "#/api/event-service/event-service.api"; +import PendingMessageService from "#/api/pending-message-service/pending-message-service.api"; import { useConversationStore } from "#/stores/conversation-store"; import { isBudgetOrCreditError, trackError } from "#/utils/error-handler"; import { useTracking } from "#/hooks/use-tracking"; @@ -47,6 +48,7 @@ import { useReadConversationFile } from "#/hooks/mutation/use-read-conversation- import useMetricsStore from "#/stores/metrics-store"; import { I18nKey } from "#/i18n/declaration"; import { useConversationHistory } from "#/hooks/query/use-conversation-history"; +import { setConversationState } from "#/utils/conversation-local-storage"; // eslint-disable-next-line @typescript-eslint/naming-convention export type V1_WebSocketConnectionState = @@ -55,9 +57,13 @@ export type V1_WebSocketConnectionState = | "CLOSED" | "CLOSING"; +interface SendMessageResult { + queued: boolean; // true if message was queued for later delivery, false if sent immediately +} + interface ConversationWebSocketContextType { connectionState: V1_WebSocketConnectionState; - sendMessage: (message: V1SendMessageRequest) => Promise; + sendMessage: (message: V1SendMessageRequest) => Promise; isLoadingHistory: boolean; } @@ -397,6 +403,10 @@ export function ConversationWebSocketProvider({ // Clear optimistic user message when a user message is confirmed if (isUserMessageEvent(event)) { removeOptimisticUserMessage(); + // Clear draft from localStorage - message was successfully delivered + if (conversationId) { + setConversationState(conversationId, { draftMessage: null }); + } } // Handle cache invalidation for ActionEvent @@ -556,6 +566,11 @@ export function ConversationWebSocketProvider({ // Clear optimistic user message when a user message is confirmed if (isUserMessageEvent(event)) { removeOptimisticUserMessage(); + // Clear draft from localStorage - message was successfully delivered + // Use main conversationId since user types in main conversation input + if (conversationId) { + setConversationState(conversationId, { draftMessage: null }); + } } // Handle cache invalidation for ActionEvent @@ -810,21 +825,44 @@ export function ConversationWebSocketProvider({ ); // V1 send message function via WebSocket + // Falls back to REST API queue when WebSocket is not connected const sendMessage = useCallback( - async (message: V1SendMessageRequest) => { + async (message: V1SendMessageRequest): Promise => { const currentMode = useConversationStore.getState().conversationMode; const currentSocket = currentMode === "plan" ? planningAgentSocket : mainSocket; if (!currentSocket || currentSocket.readyState !== WebSocket.OPEN) { - const error = "WebSocket is not connected"; - setErrorMessage(error); - throw new Error(error); + // WebSocket not connected - queue message via REST API + // Message will be delivered automatically when conversation becomes ready + if (!conversationId) { + const error = new Error("No conversation ID available"); + setErrorMessage(error.message); + throw error; + } + + try { + await PendingMessageService.queueMessage(conversationId, { + role: "user", + content: message.content, + }); + // Message queued successfully - it will be delivered when ready + // Return queued: true so caller knows not to show optimistic UI + return { queued: true }; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : "Failed to queue message for delivery"; + setErrorMessage(errorMessage); + throw error; + } } try { // Send message through WebSocket as JSON currentSocket.send(JSON.stringify(message)); + return { queued: false }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Failed to send message"; @@ -832,7 +870,7 @@ export function ConversationWebSocketProvider({ throw error; } }, - [mainSocket, planningAgentSocket, setErrorMessage], + [mainSocket, planningAgentSocket, setErrorMessage, conversationId], ); // Track main socket state changes diff --git a/frontend/src/hooks/chat/use-chat-input-logic.ts b/frontend/src/hooks/chat/use-chat-input-logic.ts index 21dc682fc9..47a6fafacb 100644 --- a/frontend/src/hooks/chat/use-chat-input-logic.ts +++ b/frontend/src/hooks/chat/use-chat-input-logic.ts @@ -5,12 +5,15 @@ import { getTextContent, } from "#/components/features/chat/utils/chat-input.utils"; import { useConversationStore } from "#/stores/conversation-store"; +import { useConversationId } from "#/hooks/use-conversation-id"; +import { useDraftPersistence } from "./use-draft-persistence"; /** * Hook for managing chat input content logic */ export const useChatInputLogic = () => { const chatInputRef = useRef(null); + const { conversationId } = useConversationId(); const { messageToSend, @@ -19,6 +22,12 @@ export const useChatInputLogic = () => { setIsRightPanelShown, } = useConversationStore(); + // Draft persistence - saves to localStorage, restores on mount + const { saveDraft, clearDraft } = useDraftPersistence( + conversationId, + chatInputRef, + ); + // Save current input value when drawer state changes useEffect(() => { if (chatInputRef.current) { @@ -51,5 +60,7 @@ export const useChatInputLogic = () => { checkIsContentEmpty, clearEmptyContentHandler, getCurrentMessage, + saveDraft, + clearDraft, }; }; diff --git a/frontend/src/hooks/chat/use-draft-persistence.ts b/frontend/src/hooks/chat/use-draft-persistence.ts new file mode 100644 index 0000000000..fd958030b1 --- /dev/null +++ b/frontend/src/hooks/chat/use-draft-persistence.ts @@ -0,0 +1,179 @@ +import { useEffect, useRef, useCallback, useState } from "react"; +import { + useConversationLocalStorageState, + getConversationState, + setConversationState, +} from "#/utils/conversation-local-storage"; +import { getTextContent } from "#/components/features/chat/utils/chat-input.utils"; + +/** + * Check if a conversation ID is a temporary task ID. + * Task IDs have the format "task-{uuid}" and are used during V1 conversation initialization. + */ +const isTaskId = (id: string): boolean => id.startsWith("task-"); + +const DRAFT_SAVE_DEBOUNCE_MS = 500; + +/** + * Hook for persisting draft messages to localStorage. + * Handles debounced saving on input, restoration on mount, and clearing on confirmed delivery. + */ +export const useDraftPersistence = ( + conversationId: string, + chatInputRef: React.RefObject, +) => { + const { state, setDraftMessage } = + useConversationLocalStorageState(conversationId); + const saveTimeoutRef = useRef | null>(null); + const hasRestoredRef = useRef(false); + const [isRestored, setIsRestored] = useState(false); + + // Track current conversationId to prevent saving draft to wrong conversation + const currentConversationIdRef = useRef(conversationId); + // Track if this is the first mount to handle initial cleanup + const isFirstMountRef = useRef(true); + + // IMPORTANT: This effect must run FIRST when conversation changes. + // It handles three concerns: + // 1. Cleanup: Cancel pending saves from previous conversation + // 2. Task-to-real transition: Preserve draft typed during initialization + // 3. DOM reset: Clear stale content before restoration effect runs + useEffect(() => { + const previousConversationId = currentConversationIdRef.current; + const isInitialMount = isFirstMountRef.current; + currentConversationIdRef.current = conversationId; + isFirstMountRef.current = false; + + // --- 1. Cancel pending saves from previous conversation --- + // Prevents draft from being saved to wrong conversation if user switched quickly + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + + const element = chatInputRef.current; + + // --- 2. Handle task-to-real ID transition (preserve draft during initialization) --- + // When a new V1 conversation initializes, it starts with a temporary "task-xxx" ID + // that transitions to a real conversation ID once ready. Task IDs don't persist + // to localStorage, so any draft typed during this phase would be lost. + // We detect this transition and transfer the draft to the new real ID. + if (!isInitialMount && previousConversationId !== conversationId) { + const wasTaskId = isTaskId(previousConversationId); + const isNowRealId = !isTaskId(conversationId); + + if (wasTaskId && isNowRealId && element) { + const currentText = getTextContent(element).trim(); + if (currentText) { + // Transfer draft to the new (real) conversation ID + setConversationState(conversationId, { draftMessage: currentText }); + // Keep draft visible in DOM and mark as restored to prevent overwrite + hasRestoredRef.current = true; + setIsRestored(true); + return; // Skip normal cleanup - draft is already in correct state + } + } + } + + // --- 3. Clear stale DOM content (will be restored by next effect if draft exists) --- + // This prevents stale drafts from appearing in new conversations due to: + // - Browser form restoration on back/forward navigation + // - React DOM recycling between conversation switches + // The restoration effect will then populate with the correct saved draft + if (element) { + element.textContent = ""; + } + + // Reset restoration flag so the restoration effect will run for new conversation + hasRestoredRef.current = false; + setIsRestored(false); + }, [conversationId, chatInputRef]); + + // Restore draft from localStorage - reads directly to avoid state sync timing issues + useEffect(() => { + if (hasRestoredRef.current) { + return; + } + + const element = chatInputRef.current; + if (!element) { + return; + } + + // Read directly from localStorage to avoid stale state from useConversationLocalStorageState + // The hook's state may not have synced yet after conversationId change + const { draftMessage } = getConversationState(conversationId); + + // Only restore if there's a saved draft and the input is empty + if (draftMessage && getTextContent(element).trim() === "") { + element.textContent = draftMessage; + // Move cursor to end + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } + + hasRestoredRef.current = true; + setIsRestored(true); + }, [chatInputRef, conversationId]); + + // Debounced save function - called from onInput handler + const saveDraft = useCallback(() => { + // Clear any pending save + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + // Capture the conversationId at the time of input + const capturedConversationId = conversationId; + + saveTimeoutRef.current = setTimeout(() => { + // Verify we're still on the same conversation before saving + // This prevents saving draft to wrong conversation if user switched quickly + if (capturedConversationId !== currentConversationIdRef.current) { + return; + } + + const element = chatInputRef.current; + if (!element) { + return; + } + + const text = getTextContent(element).trim(); + // Only save if content has changed + if (text !== (state.draftMessage || "")) { + setDraftMessage(text || null); + } + }, DRAFT_SAVE_DEBOUNCE_MS); + }, [chatInputRef, state.draftMessage, setDraftMessage, conversationId]); + + // Clear draft - called after message delivery is confirmed + const clearDraft = useCallback(() => { + // Cancel any pending save + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + setDraftMessage(null); + }, [setDraftMessage]); + + // Cleanup timeout on unmount + useEffect( + () => () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }, + [], + ); + + return { + saveDraft, + clearDraft, + isRestored, + hasDraft: !!state.draftMessage, + }; +}; diff --git a/frontend/src/hooks/use-send-message.ts b/frontend/src/hooks/use-send-message.ts index 4da5eafc2e..3f641521e6 100644 --- a/frontend/src/hooks/use-send-message.ts +++ b/frontend/src/hooks/use-send-message.ts @@ -5,6 +5,10 @@ import { useConversationWebSocket } from "#/contexts/conversation-websocket-cont import { useConversationId } from "#/hooks/use-conversation-id"; import { V1MessageContent } from "#/api/conversation-service/v1-conversation-service.types"; +interface SendResult { + queued: boolean; // true if message was queued for later delivery +} + /** * Unified hook for sending messages that works with both V0 and V1 conversations * - For V0 conversations: Uses Socket.IO WebSocket via useWsClient @@ -26,7 +30,7 @@ export function useSendMessage() { conversation?.conversation_version === "V1"; const send = useCallback( - async (event: Record) => { + async (event: Record): Promise => { if (isV1Conversation && v1Context) { // V1: Convert V0 event format to V1 message format const { action, args } = event as { @@ -57,19 +61,20 @@ export function useSendMessage() { } // Send via V1 WebSocket context (uses correct host/port) - await v1Context.sendMessage({ + const result = await v1Context.sendMessage({ role: "user", content, }); - } else { - // For non-message events, fall back to V0 send - // (e.g., agent state changes, other control events) - v0Send(event); + return result; } - } else { - // V0: Use Socket.IO + // For non-message events, fall back to V0 send + // (e.g., agent state changes, other control events) v0Send(event); + return { queued: false }; } + // V0: Use Socket.IO + v0Send(event); + return { queued: false }; }, [isV1Conversation, v1Context, v0Send, conversationId], ); diff --git a/frontend/src/utils/conversation-local-storage.ts b/frontend/src/utils/conversation-local-storage.ts index de16da9f55..4beb800b88 100644 --- a/frontend/src/utils/conversation-local-storage.ts +++ b/frontend/src/utils/conversation-local-storage.ts @@ -23,6 +23,7 @@ export interface ConversationState { unpinnedTabs: string[]; conversationMode: ConversationMode; subConversationTaskId: string | null; + draftMessage: string | null; } const DEFAULT_CONVERSATION_STATE: ConversationState = { @@ -31,6 +32,7 @@ const DEFAULT_CONVERSATION_STATE: ConversationState = { unpinnedTabs: [], conversationMode: "code", subConversationTaskId: null, + draftMessage: null, }; /** @@ -121,6 +123,7 @@ export function useConversationLocalStorageState(conversationId: string): { setRightPanelShown: (shown: boolean) => void; setUnpinnedTabs: (tabs: string[]) => void; setConversationMode: (mode: ConversationMode) => void; + setDraftMessage: (message: string | null) => void; } { const [state, setState] = useState(() => getConversationState(conversationId), @@ -178,5 +181,6 @@ export function useConversationLocalStorageState(conversationId: string): { setRightPanelShown: (shown) => updateState({ rightPanelShown: shown }), setUnpinnedTabs: (tabs) => updateState({ unpinnedTabs: tabs }), setConversationMode: (mode) => updateState({ conversationMode: mode }), + setDraftMessage: (message) => updateState({ draftMessage: message }), }; } diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 7bb59fedbf..fe07f205c1 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -59,6 +59,9 @@ from openhands.app_server.event_callback.event_callback_service import ( from openhands.app_server.event_callback.set_title_callback_processor import ( SetTitleCallbackProcessor, ) +from openhands.app_server.pending_messages.pending_message_service import ( + PendingMessageService, +) from openhands.app_server.sandbox.docker_sandbox_service import DockerSandboxService from openhands.app_server.sandbox.sandbox_models import ( AGENT_SERVER, @@ -127,6 +130,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): sandbox_service: SandboxService sandbox_spec_service: SandboxSpecService jwt_service: JwtService + pending_message_service: PendingMessageService sandbox_startup_timeout: int sandbox_startup_poll_frequency: int max_num_conversations_per_sandbox: int @@ -373,6 +377,15 @@ class LiveStatusAppConversationService(AppConversationServiceBase): task.app_conversation_id = info.id yield task + # Process any pending messages queued while waiting for conversation + if sandbox.session_api_key: + await self._process_pending_messages( + task_id=task.id, + conversation_id=info.id, + agent_server_url=agent_server_url, + session_api_key=sandbox.session_api_key, + ) + except Exception as exc: _logger.exception('Error starting conversation', stack_info=True) task.status = AppConversationStartTaskStatus.ERROR @@ -1424,6 +1437,89 @@ class LiveStatusAppConversationService(AppConversationServiceBase): plugins=plugins, ) + async def _process_pending_messages( + self, + task_id: UUID, + conversation_id: UUID, + agent_server_url: str, + session_api_key: str, + ) -> None: + """Process pending messages queued before conversation was ready. + + Messages are delivered concurrently to the agent server. After processing, + all messages are deleted from the database regardless of success or failure. + + Args: + task_id: The start task ID (may have been used as conversation_id initially) + conversation_id: The real conversation ID + agent_server_url: URL of the agent server + session_api_key: API key for authenticating with agent server + """ + # Convert UUIDs to strings for the pending message service + # The frontend uses task-{uuid.hex} format (no hyphens), matching OpenHandsUUID serialization + task_id_str = f'task-{task_id.hex}' + # conversation_id uses standard format (with hyphens) for agent server API compatibility + conversation_id_str = str(conversation_id) + + _logger.info(f'task_id={task_id_str} conversation_id={conversation_id_str}') + + # First, update any messages that were queued with the task_id + updated_count = await self.pending_message_service.update_conversation_id( + old_conversation_id=task_id_str, + new_conversation_id=conversation_id_str, + ) + _logger.info(f'updated_count={updated_count} ') + if updated_count > 0: + _logger.info( + f'Updated {updated_count} pending messages from task_id={task_id_str} ' + f'to conversation_id={conversation_id_str}' + ) + + # Get all pending messages for this conversation + pending_messages = await self.pending_message_service.get_pending_messages( + conversation_id_str + ) + + if not pending_messages: + return + + _logger.info( + f'Processing {len(pending_messages)} pending messages for ' + f'conversation {conversation_id_str}' + ) + + # Process messages sequentially to preserve order + for msg in pending_messages: + try: + # Serialize content objects to JSON-compatible dicts + content_json = [item.model_dump() for item in msg.content] + # Use the events endpoint which handles message sending + response = await self.httpx_client.post( + f'{agent_server_url}/api/conversations/{conversation_id_str}/events', + json={ + 'role': msg.role, + 'content': content_json, + 'run': True, + }, + headers={'X-Session-API-Key': session_api_key}, + timeout=30.0, + ) + response.raise_for_status() + _logger.debug(f'Delivered pending message {msg.id}') + except Exception as e: + _logger.warning(f'Failed to deliver pending message {msg.id}: {e}') + + # Delete all pending messages after processing (regardless of success/failure) + deleted_count = ( + await self.pending_message_service.delete_messages_for_conversation( + conversation_id_str + ) + ) + _logger.info( + f'Finished processing pending messages for conversation {conversation_id_str}. ' + f'Deleted {deleted_count} messages.' + ) + async def update_agent_server_conversation_title( self, conversation_id: str, @@ -1796,6 +1892,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): get_global_config, get_httpx_client, get_jwt_service, + get_pending_message_service, get_sandbox_service, get_sandbox_spec_service, get_user_context, @@ -1815,6 +1912,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): get_event_service(state, request) as event_service, get_jwt_service(state, request) as jwt_service, get_httpx_client(state, request) as httpx_client, + get_pending_message_service(state, request) as pending_message_service, ): access_token_hard_timeout = None if self.access_token_hard_timeout: @@ -1859,6 +1957,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): event_callback_service=event_callback_service, event_service=event_service, jwt_service=jwt_service, + pending_message_service=pending_message_service, sandbox_startup_timeout=self.sandbox_startup_timeout, sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency, max_num_conversations_per_sandbox=self.max_num_conversations_per_sandbox, diff --git a/openhands/app_server/app_lifespan/alembic/versions/007.py b/openhands/app_server/app_lifespan/alembic/versions/007.py new file mode 100644 index 0000000000..ef0b34b2eb --- /dev/null +++ b/openhands/app_server/app_lifespan/alembic/versions/007.py @@ -0,0 +1,39 @@ +"""Add pending_messages table for server-side message queuing + +Revision ID: 007 +Revises: 006 +Create Date: 2025-03-15 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '007' +down_revision: Union[str, None] = '006' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create pending_messages table for storing messages before conversation is ready. + + Messages are stored temporarily until the conversation becomes ready, then + delivered and deleted regardless of success or failure. + """ + op.create_table( + 'pending_messages', + sa.Column('id', sa.String(), primary_key=True), + sa.Column('conversation_id', sa.String(), nullable=False, index=True), + sa.Column('role', sa.String(20), nullable=False, server_default='user'), + sa.Column('content', sa.JSON, nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + ) + + +def downgrade() -> None: + """Remove pending_messages table.""" + op.drop_table('pending_messages') diff --git a/openhands/app_server/config.py b/openhands/app_server/config.py index 8c0ded6d2e..4b7f78e389 100644 --- a/openhands/app_server/config.py +++ b/openhands/app_server/config.py @@ -33,6 +33,10 @@ from openhands.app_server.event_callback.event_callback_service import ( EventCallbackService, EventCallbackServiceInjector, ) +from openhands.app_server.pending_messages.pending_message_service import ( + PendingMessageService, + PendingMessageServiceInjector, +) from openhands.app_server.sandbox.sandbox_service import ( SandboxService, SandboxServiceInjector, @@ -114,6 +118,7 @@ class AppServerConfig(OpenHandsModel): app_conversation_info: AppConversationInfoServiceInjector | None = None app_conversation_start_task: AppConversationStartTaskServiceInjector | None = None app_conversation: AppConversationServiceInjector | None = None + pending_message: PendingMessageServiceInjector | None = None user: UserContextInjector | None = None jwt: JwtServiceInjector | None = None httpx: HttpxClientInjector = Field(default_factory=HttpxClientInjector) @@ -280,6 +285,13 @@ def config_from_env() -> AppServerConfig: tavily_api_key=tavily_api_key ) + if config.pending_message is None: + from openhands.app_server.pending_messages.pending_message_service import ( + SQLPendingMessageServiceInjector, + ) + + config.pending_message = SQLPendingMessageServiceInjector() + if config.user is None: config.user = AuthUserContextInjector() @@ -358,6 +370,14 @@ def get_app_conversation_service( return injector.context(state, request) +def get_pending_message_service( + state: InjectorState, request: Request | None = None +) -> AsyncContextManager[PendingMessageService]: + injector = get_global_config().pending_message + assert injector is not None + return injector.context(state, request) + + def get_user_context( state: InjectorState, request: Request | None = None ) -> AsyncContextManager[UserContext]: @@ -433,6 +453,12 @@ def depends_app_conversation_service(): return Depends(injector.depends) +def depends_pending_message_service(): + injector = get_global_config().pending_message + assert injector is not None + return Depends(injector.depends) + + def depends_user_context(): injector = get_global_config().user assert injector is not None diff --git a/openhands/app_server/pending_messages/__init__.py b/openhands/app_server/pending_messages/__init__.py new file mode 100644 index 0000000000..5aa37fc675 --- /dev/null +++ b/openhands/app_server/pending_messages/__init__.py @@ -0,0 +1,21 @@ +"""Pending messages module for server-side message queuing.""" + +from openhands.app_server.pending_messages.pending_message_models import ( + PendingMessage, + PendingMessageResponse, +) +from openhands.app_server.pending_messages.pending_message_service import ( + PendingMessageService, + PendingMessageServiceInjector, + SQLPendingMessageService, + SQLPendingMessageServiceInjector, +) + +__all__ = [ + 'PendingMessage', + 'PendingMessageResponse', + 'PendingMessageService', + 'PendingMessageServiceInjector', + 'SQLPendingMessageService', + 'SQLPendingMessageServiceInjector', +] diff --git a/openhands/app_server/pending_messages/pending_message_models.py b/openhands/app_server/pending_messages/pending_message_models.py new file mode 100644 index 0000000000..9e0062b185 --- /dev/null +++ b/openhands/app_server/pending_messages/pending_message_models.py @@ -0,0 +1,32 @@ +"""Models for pending message queue functionality.""" + +from datetime import datetime +from uuid import uuid4 + +from pydantic import BaseModel, Field + +from openhands.agent_server.models import ImageContent, TextContent +from openhands.agent_server.utils import utc_now + + +class PendingMessage(BaseModel): + """A message queued for delivery when conversation becomes ready. + + Pending messages are stored in the database and delivered to the agent_server + when the conversation transitions to READY status. Messages are deleted after + processing, regardless of success or failure. + """ + + id: str = Field(default_factory=lambda: str(uuid4())) + conversation_id: str # Can be task-{uuid} or real conversation UUID + role: str = 'user' + content: list[TextContent | ImageContent] + created_at: datetime = Field(default_factory=utc_now) + + +class PendingMessageResponse(BaseModel): + """Response when queueing a pending message.""" + + id: str + queued: bool + position: int = Field(description='Position in the queue (1-based)') diff --git a/openhands/app_server/pending_messages/pending_message_router.py b/openhands/app_server/pending_messages/pending_message_router.py new file mode 100644 index 0000000000..7c78e2d6eb --- /dev/null +++ b/openhands/app_server/pending_messages/pending_message_router.py @@ -0,0 +1,104 @@ +"""REST API router for pending messages.""" + +import logging + +from fastapi import APIRouter, HTTPException, Request, status +from pydantic import TypeAdapter, ValidationError + +from openhands.agent_server.models import ImageContent, TextContent +from openhands.app_server.config import depends_pending_message_service +from openhands.app_server.pending_messages.pending_message_models import ( + PendingMessageResponse, +) +from openhands.app_server.pending_messages.pending_message_service import ( + PendingMessageService, +) +from openhands.server.dependencies import get_dependencies + +logger = logging.getLogger(__name__) + +# Type adapter for validating content from request +_content_type_adapter = TypeAdapter(list[TextContent | ImageContent]) + +# Create router with authentication dependencies +router = APIRouter( + prefix='/conversations/{conversation_id}/pending-messages', + tags=['Pending Messages'], + dependencies=get_dependencies(), +) + +# Create dependency at module level +pending_message_service_dependency = depends_pending_message_service() + + +@router.post( + '', response_model=PendingMessageResponse, status_code=status.HTTP_201_CREATED +) +async def queue_pending_message( + conversation_id: str, + request: Request, + pending_service: PendingMessageService = pending_message_service_dependency, +) -> PendingMessageResponse: + """Queue a message for delivery when conversation becomes ready. + + This endpoint allows users to submit messages even when the conversation's + WebSocket connection is not yet established. Messages are stored server-side + and delivered automatically when the conversation transitions to READY status. + + Args: + conversation_id: The conversation ID (can be task ID before conversation is ready) + request: The FastAPI request containing message content + + Returns: + PendingMessageResponse with the message ID and queue position + + Raises: + HTTPException 400: If the request body is invalid + HTTPException 429: If too many pending messages are queued (limit: 10) + """ + try: + body = await request.json() + except Exception: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Invalid request body', + ) + + raw_content = body.get('content') + role = body.get('role', 'user') + + if not raw_content or not isinstance(raw_content, list): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='content must be a non-empty list', + ) + + # Validate and parse content into typed objects + try: + content = _content_type_adapter.validate_python(raw_content) + except ValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Invalid content format: {e}', + ) + + # Rate limit: max 10 pending messages per conversation + pending_count = await pending_service.count_pending_messages(conversation_id) + if pending_count >= 10: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail='Too many pending messages. Maximum 10 messages per conversation.', + ) + + response = await pending_service.add_message( + conversation_id=conversation_id, + content=content, + role=role, + ) + + logger.info( + f'Queued pending message {response.id} for conversation {conversation_id} ' + f'(position: {response.position})' + ) + + return response diff --git a/openhands/app_server/pending_messages/pending_message_service.py b/openhands/app_server/pending_messages/pending_message_service.py new file mode 100644 index 0000000000..44d426c409 --- /dev/null +++ b/openhands/app_server/pending_messages/pending_message_service.py @@ -0,0 +1,200 @@ +"""Service for managing pending messages in SQL database.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import AsyncGenerator + +from fastapi import Request +from pydantic import TypeAdapter +from sqlalchemy import JSON, Column, String, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from openhands.agent_server.models import ImageContent, TextContent +from openhands.app_server.pending_messages.pending_message_models import ( + PendingMessage, + PendingMessageResponse, +) +from openhands.app_server.services.injector import Injector, InjectorState +from openhands.app_server.utils.sql_utils import Base, UtcDateTime +from openhands.sdk.utils.models import DiscriminatedUnionMixin + +# Type adapter for deserializing content from JSON +_content_type_adapter = TypeAdapter(list[TextContent | ImageContent]) + + +class StoredPendingMessage(Base): # type: ignore + """SQLAlchemy model for pending messages.""" + + __tablename__ = 'pending_messages' + id = Column(String, primary_key=True) + conversation_id = Column(String, nullable=False, index=True) + role = Column(String(20), nullable=False, default='user') + content = Column(JSON, nullable=False) + created_at = Column(UtcDateTime, server_default=func.now(), index=True) + + +class PendingMessageService(ABC): + """Abstract service for managing pending messages.""" + + @abstractmethod + async def add_message( + self, + conversation_id: str, + content: list[TextContent | ImageContent], + role: str = 'user', + ) -> PendingMessageResponse: + """Queue a message for delivery when conversation becomes ready.""" + + @abstractmethod + async def get_pending_messages(self, conversation_id: str) -> list[PendingMessage]: + """Get all pending messages for a conversation, ordered by created_at.""" + + @abstractmethod + async def count_pending_messages(self, conversation_id: str) -> int: + """Count pending messages for a conversation.""" + + @abstractmethod + async def delete_messages_for_conversation(self, conversation_id: str) -> int: + """Delete all pending messages for a conversation, returning count deleted.""" + + @abstractmethod + async def update_conversation_id( + self, old_conversation_id: str, new_conversation_id: str + ) -> int: + """Update conversation_id when task-id transitions to real conversation-id. + + Returns the number of messages updated. + """ + + +@dataclass +class SQLPendingMessageService(PendingMessageService): + """SQL implementation of PendingMessageService.""" + + db_session: AsyncSession + + async def add_message( + self, + conversation_id: str, + content: list[TextContent | ImageContent], + role: str = 'user', + ) -> PendingMessageResponse: + """Queue a message for delivery when conversation becomes ready.""" + # Create the pending message + pending_message = PendingMessage( + conversation_id=conversation_id, + role=role, + content=content, + ) + + # Count existing pending messages for position + count_stmt = select(func.count()).where( + StoredPendingMessage.conversation_id == conversation_id + ) + result = await self.db_session.execute(count_stmt) + position = result.scalar() or 0 + + # Serialize content to JSON-compatible format for storage + content_json = [item.model_dump() for item in content] + + # Store in database + stored_message = StoredPendingMessage( + id=str(pending_message.id), + conversation_id=conversation_id, + role=role, + content=content_json, + created_at=pending_message.created_at, + ) + self.db_session.add(stored_message) + await self.db_session.commit() + + return PendingMessageResponse( + id=pending_message.id, + queued=True, + position=position + 1, + ) + + async def get_pending_messages(self, conversation_id: str) -> list[PendingMessage]: + """Get all pending messages for a conversation, ordered by created_at.""" + stmt = ( + select(StoredPendingMessage) + .where(StoredPendingMessage.conversation_id == conversation_id) + .order_by(StoredPendingMessage.created_at.asc()) + ) + result = await self.db_session.execute(stmt) + stored_messages = result.scalars().all() + + return [ + PendingMessage( + id=msg.id, + conversation_id=msg.conversation_id, + role=msg.role, + content=_content_type_adapter.validate_python(msg.content), + created_at=msg.created_at, + ) + for msg in stored_messages + ] + + async def count_pending_messages(self, conversation_id: str) -> int: + """Count pending messages for a conversation.""" + count_stmt = select(func.count()).where( + StoredPendingMessage.conversation_id == conversation_id + ) + result = await self.db_session.execute(count_stmt) + return result.scalar() or 0 + + async def delete_messages_for_conversation(self, conversation_id: str) -> int: + """Delete all pending messages for a conversation, returning count deleted.""" + stmt = select(StoredPendingMessage).where( + StoredPendingMessage.conversation_id == conversation_id + ) + result = await self.db_session.execute(stmt) + stored_messages = result.scalars().all() + + count = len(stored_messages) + for msg in stored_messages: + await self.db_session.delete(msg) + + if count > 0: + await self.db_session.commit() + + return count + + async def update_conversation_id( + self, old_conversation_id: str, new_conversation_id: str + ) -> int: + """Update conversation_id when task-id transitions to real conversation-id.""" + stmt = select(StoredPendingMessage).where( + StoredPendingMessage.conversation_id == old_conversation_id + ) + result = await self.db_session.execute(stmt) + stored_messages = result.scalars().all() + + count = len(stored_messages) + for msg in stored_messages: + msg.conversation_id = new_conversation_id + + if count > 0: + await self.db_session.commit() + + return count + + +class PendingMessageServiceInjector( + DiscriminatedUnionMixin, Injector[PendingMessageService], ABC +): + """Abstract injector for PendingMessageService.""" + + pass + + +class SQLPendingMessageServiceInjector(PendingMessageServiceInjector): + """SQL-based injector for PendingMessageService.""" + + async def inject( + self, state: InjectorState, request: Request | None = None + ) -> AsyncGenerator[PendingMessageService, None]: + from openhands.app_server.config import get_db_session + + async with get_db_session(state) as db_session: + yield SQLPendingMessageService(db_session=db_session) diff --git a/openhands/app_server/v1_router.py b/openhands/app_server/v1_router.py index 2a21c06abd..81823b481c 100644 --- a/openhands/app_server/v1_router.py +++ b/openhands/app_server/v1_router.py @@ -5,6 +5,9 @@ from openhands.app_server.event import event_router from openhands.app_server.event_callback import ( webhook_router, ) +from openhands.app_server.pending_messages.pending_message_router import ( + router as pending_message_router, +) from openhands.app_server.sandbox import sandbox_router, sandbox_spec_router from openhands.app_server.user import user_router from openhands.app_server.web_client import web_client_router @@ -13,6 +16,7 @@ from openhands.app_server.web_client import web_client_router router = APIRouter(prefix='/api/v1') router.include_router(event_router.router) router.include_router(app_conversation_router.router) +router.include_router(pending_message_router) router.include_router(sandbox_router.router) router.include_router(sandbox_spec_router.router) router.include_router(user_router.router) diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index cf32cfaf05..fcb251797f 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -80,6 +80,7 @@ class TestLiveStatusAppConversationService: self.mock_event_callback_service = Mock() self.mock_event_service = Mock() self.mock_httpx_client = Mock() + self.mock_pending_message_service = Mock() # Create service instance self.service = LiveStatusAppConversationService( @@ -92,6 +93,7 @@ class TestLiveStatusAppConversationService: sandbox_service=self.mock_sandbox_service, sandbox_spec_service=self.mock_sandbox_spec_service, jwt_service=self.mock_jwt_service, + pending_message_service=self.mock_pending_message_service, sandbox_startup_timeout=30, sandbox_startup_poll_frequency=1, max_num_conversations_per_sandbox=20, @@ -2329,6 +2331,7 @@ class TestPluginHandling: self.mock_event_callback_service = Mock() self.mock_event_service = Mock() self.mock_httpx_client = Mock() + self.mock_pending_message_service = Mock() # Create service instance self.service = LiveStatusAppConversationService( @@ -2341,6 +2344,7 @@ class TestPluginHandling: sandbox_service=self.mock_sandbox_service, sandbox_spec_service=self.mock_sandbox_spec_service, jwt_service=self.mock_jwt_service, + pending_message_service=self.mock_pending_message_service, sandbox_startup_timeout=30, sandbox_startup_poll_frequency=1, max_num_conversations_per_sandbox=20, diff --git a/tests/unit/app_server/test_pending_message_router.py b/tests/unit/app_server/test_pending_message_router.py new file mode 100644 index 0000000000..92dbe2c4a4 --- /dev/null +++ b/tests/unit/app_server/test_pending_message_router.py @@ -0,0 +1,227 @@ +"""Unit tests for the pending_message_router endpoints. + +This module tests the queue_pending_message endpoint, +focusing on request validation and rate limiting. +""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException, status + +from openhands.agent_server.models import TextContent +from openhands.app_server.pending_messages.pending_message_models import ( + PendingMessageResponse, +) +from openhands.app_server.pending_messages.pending_message_router import ( + queue_pending_message, +) + + +def _make_mock_service( + add_message_return=None, + count_pending_messages_return=0, +): + """Create a mock PendingMessageService for testing.""" + service = MagicMock() + service.add_message = AsyncMock(return_value=add_message_return) + service.count_pending_messages = AsyncMock( + return_value=count_pending_messages_return + ) + return service + + +def _make_mock_request(body: dict): + """Create a mock FastAPI Request with given JSON body.""" + request = MagicMock() + request.json = AsyncMock(return_value=body) + return request + + +@pytest.mark.asyncio +class TestQueuePendingMessage: + """Test suite for queue_pending_message endpoint.""" + + async def test_queues_message_successfully(self): + """Test that a valid message is queued successfully.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + raw_content = [{'type': 'text', 'text': 'Hello, world!'}] + expected_response = PendingMessageResponse( + id=str(uuid4()), + queued=True, + position=1, + ) + mock_service = _make_mock_service( + add_message_return=expected_response, + count_pending_messages_return=0, + ) + mock_request = _make_mock_request({'content': raw_content, 'role': 'user'}) + + # Act + result = await queue_pending_message( + conversation_id=conversation_id, + request=mock_request, + pending_service=mock_service, + ) + + # Assert + assert result == expected_response + mock_service.add_message.assert_called_once() + call_kwargs = mock_service.add_message.call_args.kwargs + assert call_kwargs['conversation_id'] == conversation_id + assert call_kwargs['role'] == 'user' + # Content should be parsed into typed objects + assert len(call_kwargs['content']) == 1 + assert isinstance(call_kwargs['content'][0], TextContent) + assert call_kwargs['content'][0].text == 'Hello, world!' + + async def test_uses_default_role_when_not_provided(self): + """Test that 'user' role is used by default.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + raw_content = [{'type': 'text', 'text': 'Test message'}] + expected_response = PendingMessageResponse( + id=str(uuid4()), + queued=True, + position=1, + ) + mock_service = _make_mock_service( + add_message_return=expected_response, + count_pending_messages_return=0, + ) + mock_request = _make_mock_request({'content': raw_content}) + + # Act + await queue_pending_message( + conversation_id=conversation_id, + request=mock_request, + pending_service=mock_service, + ) + + # Assert + mock_service.add_message.assert_called_once() + call_kwargs = mock_service.add_message.call_args.kwargs + assert call_kwargs['conversation_id'] == conversation_id + assert call_kwargs['role'] == 'user' + assert isinstance(call_kwargs['content'][0], TextContent) + + async def test_returns_400_for_invalid_json_body(self): + """Test that invalid JSON body returns 400 Bad Request.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + mock_service = _make_mock_service() + mock_request = MagicMock() + mock_request.json = AsyncMock(side_effect=Exception('Invalid JSON')) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await queue_pending_message( + conversation_id=conversation_id, + request=mock_request, + pending_service=mock_service, + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert 'Invalid request body' in exc_info.value.detail + + async def test_returns_400_when_content_is_missing(self): + """Test that missing content returns 400 Bad Request.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + mock_service = _make_mock_service() + mock_request = _make_mock_request({'role': 'user'}) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await queue_pending_message( + conversation_id=conversation_id, + request=mock_request, + pending_service=mock_service, + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert 'content must be a non-empty list' in exc_info.value.detail + + async def test_returns_400_when_content_is_not_a_list(self): + """Test that non-list content returns 400 Bad Request.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + mock_service = _make_mock_service() + mock_request = _make_mock_request({'content': 'not a list'}) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await queue_pending_message( + conversation_id=conversation_id, + request=mock_request, + pending_service=mock_service, + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert 'content must be a non-empty list' in exc_info.value.detail + + async def test_returns_400_when_content_is_empty_list(self): + """Test that empty list content returns 400 Bad Request.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + mock_service = _make_mock_service() + mock_request = _make_mock_request({'content': []}) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await queue_pending_message( + conversation_id=conversation_id, + request=mock_request, + pending_service=mock_service, + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert 'content must be a non-empty list' in exc_info.value.detail + + async def test_returns_429_when_rate_limit_exceeded(self): + """Test that exceeding rate limit returns 429 Too Many Requests.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + raw_content = [{'type': 'text', 'text': 'Test message'}] + mock_service = _make_mock_service(count_pending_messages_return=10) + mock_request = _make_mock_request({'content': raw_content}) + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await queue_pending_message( + conversation_id=conversation_id, + request=mock_request, + pending_service=mock_service, + ) + + assert exc_info.value.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert 'Maximum 10 messages' in exc_info.value.detail + + async def test_allows_up_to_10_messages(self): + """Test that 9 existing messages still allows adding one more.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + raw_content = [{'type': 'text', 'text': 'Test message'}] + expected_response = PendingMessageResponse( + id=str(uuid4()), + queued=True, + position=10, + ) + mock_service = _make_mock_service( + add_message_return=expected_response, + count_pending_messages_return=9, + ) + mock_request = _make_mock_request({'content': raw_content}) + + # Act + result = await queue_pending_message( + conversation_id=conversation_id, + request=mock_request, + pending_service=mock_service, + ) + + # Assert + assert result == expected_response + mock_service.add_message.assert_called_once() diff --git a/tests/unit/app_server/test_pending_message_service.py b/tests/unit/app_server/test_pending_message_service.py new file mode 100644 index 0000000000..869aae05d0 --- /dev/null +++ b/tests/unit/app_server/test_pending_message_service.py @@ -0,0 +1,309 @@ +"""Tests for SQLPendingMessageService. + +This module tests the SQL implementation of PendingMessageService, +covering message queuing, retrieval, counting, deletion, and +conversation_id updates using SQLite as a mock database. +""" + +from typing import AsyncGenerator +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from openhands.agent_server.models import TextContent +from openhands.app_server.pending_messages.pending_message_models import ( + PendingMessageResponse, +) +from openhands.app_server.pending_messages.pending_message_service import ( + SQLPendingMessageService, +) +from openhands.app_server.utils.sql_utils import Base + + +@pytest.fixture +async def async_engine(): + """Create an async SQLite engine for testing.""" + engine = create_async_engine( + 'sqlite+aiosqlite:///:memory:', + poolclass=StaticPool, + connect_args={'check_same_thread': False}, + echo=False, + ) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + yield engine + + await engine.dispose() + + +@pytest.fixture +async def async_session(async_engine) -> AsyncGenerator[AsyncSession, None]: + """Create an async session for testing.""" + async_session_maker = async_sessionmaker( + async_engine, class_=AsyncSession, expire_on_commit=False + ) + + async with async_session_maker() as db_session: + yield db_session + + +@pytest.fixture +def service(async_session) -> SQLPendingMessageService: + """Create a SQLPendingMessageService instance for testing.""" + return SQLPendingMessageService(db_session=async_session) + + +@pytest.fixture +def sample_content() -> list[TextContent]: + """Create sample message content for testing.""" + return [TextContent(text='Hello, this is a test message')] + + +class TestSQLPendingMessageService: + """Test suite for SQLPendingMessageService.""" + + @pytest.mark.asyncio + async def test_add_message_creates_message_with_correct_data( + self, + service: SQLPendingMessageService, + sample_content: list[TextContent], + ): + """Test that add_message creates a message with the expected fields.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + + # Act + response = await service.add_message( + conversation_id=conversation_id, + content=sample_content, + role='user', + ) + + # Assert + assert isinstance(response, PendingMessageResponse) + assert response.queued is True + assert response.id is not None + + # Verify the message was stored correctly + messages = await service.get_pending_messages(conversation_id) + assert len(messages) == 1 + assert messages[0].conversation_id == conversation_id + assert len(messages[0].content) == 1 + assert isinstance(messages[0].content[0], TextContent) + assert messages[0].content[0].text == sample_content[0].text + assert messages[0].role == 'user' + assert messages[0].created_at is not None + + @pytest.mark.asyncio + async def test_add_message_returns_correct_queue_position( + self, + service: SQLPendingMessageService, + sample_content: list[TextContent], + ): + """Test that queue position increments correctly for each message.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + + # Act - Add three messages + response1 = await service.add_message(conversation_id, sample_content) + response2 = await service.add_message(conversation_id, sample_content) + response3 = await service.add_message(conversation_id, sample_content) + + # Assert + assert response1.position == 1 + assert response2.position == 2 + assert response3.position == 3 + + @pytest.mark.asyncio + async def test_get_pending_messages_returns_messages_ordered_by_created_at( + self, + service: SQLPendingMessageService, + ): + """Test that messages are returned in chronological order.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + contents = [ + [TextContent(text='First message')], + [TextContent(text='Second message')], + [TextContent(text='Third message')], + ] + + for content in contents: + await service.add_message(conversation_id, content) + + # Act + messages = await service.get_pending_messages(conversation_id) + + # Assert + assert len(messages) == 3 + assert messages[0].content[0].text == 'First message' + assert messages[1].content[0].text == 'Second message' + assert messages[2].content[0].text == 'Third message' + + @pytest.mark.asyncio + async def test_get_pending_messages_returns_empty_list_when_none_exist( + self, + service: SQLPendingMessageService, + ): + """Test that an empty list is returned for a conversation with no messages.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + + # Act + messages = await service.get_pending_messages(conversation_id) + + # Assert + assert messages == [] + + @pytest.mark.asyncio + async def test_count_pending_messages_returns_correct_count( + self, + service: SQLPendingMessageService, + sample_content: list[TextContent], + ): + """Test that count_pending_messages returns the correct number.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + other_conversation_id = f'task-{uuid4().hex}' + + # Add 3 messages to first conversation + for _ in range(3): + await service.add_message(conversation_id, sample_content) + + # Add 2 messages to second conversation + for _ in range(2): + await service.add_message(other_conversation_id, sample_content) + + # Act + count1 = await service.count_pending_messages(conversation_id) + count2 = await service.count_pending_messages(other_conversation_id) + count_empty = await service.count_pending_messages('nonexistent') + + # Assert + assert count1 == 3 + assert count2 == 2 + assert count_empty == 0 + + @pytest.mark.asyncio + async def test_delete_messages_for_conversation_removes_all_messages( + self, + service: SQLPendingMessageService, + sample_content: list[TextContent], + ): + """Test that delete_messages_for_conversation removes all messages and returns count.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + other_conversation_id = f'task-{uuid4().hex}' + + # Add messages to both conversations + for _ in range(3): + await service.add_message(conversation_id, sample_content) + await service.add_message(other_conversation_id, sample_content) + + # Act + deleted_count = await service.delete_messages_for_conversation(conversation_id) + + # Assert + assert deleted_count == 3 + assert await service.count_pending_messages(conversation_id) == 0 + # Other conversation should be unaffected + assert await service.count_pending_messages(other_conversation_id) == 1 + + @pytest.mark.asyncio + async def test_delete_messages_for_conversation_returns_zero_when_none_exist( + self, + service: SQLPendingMessageService, + ): + """Test that deleting from nonexistent conversation returns 0.""" + # Arrange + conversation_id = f'task-{uuid4().hex}' + + # Act + deleted_count = await service.delete_messages_for_conversation(conversation_id) + + # Assert + assert deleted_count == 0 + + @pytest.mark.asyncio + async def test_update_conversation_id_updates_all_matching_messages( + self, + service: SQLPendingMessageService, + sample_content: list[TextContent], + ): + """Test that update_conversation_id updates all messages with the old ID.""" + # Arrange + old_conversation_id = f'task-{uuid4().hex}' + new_conversation_id = str(uuid4()) + unrelated_conversation_id = f'task-{uuid4().hex}' + + # Add messages to old conversation + for _ in range(3): + await service.add_message(old_conversation_id, sample_content) + + # Add message to unrelated conversation + await service.add_message(unrelated_conversation_id, sample_content) + + # Act + updated_count = await service.update_conversation_id( + old_conversation_id, new_conversation_id + ) + + # Assert + assert updated_count == 3 + + # Verify old conversation has no messages + assert await service.count_pending_messages(old_conversation_id) == 0 + + # Verify new conversation has all messages + messages = await service.get_pending_messages(new_conversation_id) + assert len(messages) == 3 + for msg in messages: + assert msg.conversation_id == new_conversation_id + + # Verify unrelated conversation is unchanged + assert await service.count_pending_messages(unrelated_conversation_id) == 1 + + @pytest.mark.asyncio + async def test_update_conversation_id_returns_zero_when_no_match( + self, + service: SQLPendingMessageService, + ): + """Test that updating nonexistent conversation_id returns 0.""" + # Arrange + old_conversation_id = f'task-{uuid4().hex}' + new_conversation_id = str(uuid4()) + + # Act + updated_count = await service.update_conversation_id( + old_conversation_id, new_conversation_id + ) + + # Assert + assert updated_count == 0 + + @pytest.mark.asyncio + async def test_messages_are_isolated_between_conversations( + self, + service: SQLPendingMessageService, + ): + """Test that operations on one conversation don't affect others.""" + # Arrange + conv1 = f'task-{uuid4().hex}' + conv2 = f'task-{uuid4().hex}' + + await service.add_message(conv1, [TextContent(text='Conv1 msg')]) + await service.add_message(conv2, [TextContent(text='Conv2 msg')]) + + # Act + messages1 = await service.get_pending_messages(conv1) + messages2 = await service.get_pending_messages(conv2) + + # Assert + assert len(messages1) == 1 + assert len(messages2) == 1 + assert messages1[0].content[0].text == 'Conv1 msg' + assert messages2[0].content[0].text == 'Conv2 msg' diff --git a/tests/unit/server/data_models/test_conversation.py b/tests/unit/server/data_models/test_conversation.py index 7fa64ab12a..99dbdfaacc 100644 --- a/tests/unit/server/data_models/test_conversation.py +++ b/tests/unit/server/data_models/test_conversation.py @@ -2187,6 +2187,7 @@ async def test_delete_v1_conversation_with_sub_conversations(): sandbox_service=mock_sandbox_service, sandbox_spec_service=MagicMock(), jwt_service=MagicMock(), + pending_message_service=MagicMock(), sandbox_startup_timeout=120, sandbox_startup_poll_frequency=2, max_num_conversations_per_sandbox=20, @@ -2311,6 +2312,7 @@ async def test_delete_v1_conversation_with_no_sub_conversations(): sandbox_service=mock_sandbox_service, sandbox_spec_service=MagicMock(), jwt_service=MagicMock(), + pending_message_service=MagicMock(), sandbox_startup_timeout=120, sandbox_startup_poll_frequency=2, max_num_conversations_per_sandbox=20, @@ -2465,6 +2467,7 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error(): sandbox_service=mock_sandbox_service, sandbox_spec_service=MagicMock(), jwt_service=MagicMock(), + pending_message_service=MagicMock(), sandbox_startup_timeout=120, sandbox_startup_poll_frequency=2, max_num_conversations_per_sandbox=20, From a0e777503ee846f3d7c601c9076ce9f80ca4582d Mon Sep 17 00:00:00 2001 From: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:22:23 -0700 Subject: [PATCH 32/92] fix(frontend): prevent auto sandbox resume behavior (#13133) Co-authored-by: openhands --- .../hooks/use-sandbox-recovery.test.tsx | 577 ++++++++++++++++++ .../hooks/use-visibility-recovery.test.ts | 286 +++++++++ .../conversation-websocket-context.tsx | 22 +- .../contexts/websocket-provider-wrapper.tsx | 35 +- frontend/src/hooks/use-sandbox-recovery.ts | 138 +++++ frontend/src/hooks/use-visibility-change.ts | 64 ++ frontend/src/hooks/use-websocket-recovery.ts | 110 ---- frontend/src/routes/conversation.tsx | 63 +- 8 files changed, 1091 insertions(+), 204 deletions(-) create mode 100644 frontend/__tests__/hooks/use-sandbox-recovery.test.tsx create mode 100644 frontend/__tests__/hooks/use-visibility-recovery.test.ts create mode 100644 frontend/src/hooks/use-sandbox-recovery.ts create mode 100644 frontend/src/hooks/use-visibility-change.ts delete mode 100644 frontend/src/hooks/use-websocket-recovery.ts diff --git a/frontend/__tests__/hooks/use-sandbox-recovery.test.tsx b/frontend/__tests__/hooks/use-sandbox-recovery.test.tsx new file mode 100644 index 0000000000..638fe21788 --- /dev/null +++ b/frontend/__tests__/hooks/use-sandbox-recovery.test.tsx @@ -0,0 +1,577 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import { useSandboxRecovery } from "#/hooks/use-sandbox-recovery"; +import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation"; +import * as customToastHandlers from "#/utils/custom-toast-handlers"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("#/hooks/use-user-providers", () => ({ + useUserProviders: () => ({ + providers: [{ provider: "github", token: "test-token" }], + }), +})); + +vi.mock("#/utils/custom-toast-handlers"); +vi.mock("#/hooks/mutation/use-unified-start-conversation"); + +describe("useSandboxRecovery", () => { + let mockMutate: ReturnType; + + const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockMutate = vi.fn(); + + vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: false, + isSuccess: false, + isError: false, + isIdle: true, + data: undefined, + error: null, + reset: vi.fn(), + status: "idle", + variables: undefined, + failureCount: 0, + failureReason: null, + submittedAt: 0, + context: undefined, + } as unknown as ReturnType); + + // Reset document.visibilityState + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("initial load recovery", () => { + it("should call resumeSandbox on initial load when conversation is STOPPED", () => { + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "STOPPED", + }), + { wrapper: createWrapper() }, + ); + + expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate).toHaveBeenCalledWith( + { + conversationId: "conv-123", + providers: [{ provider: "github", token: "test-token" }], + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + + it("should NOT call resumeSandbox on initial load when conversation is RUNNING", () => { + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "RUNNING", + }), + { wrapper: createWrapper() }, + ); + + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("should NOT call resumeSandbox when conversationId is undefined", () => { + renderHook( + () => + useSandboxRecovery({ + conversationId: undefined, + conversationStatus: "STOPPED", + }), + { wrapper: createWrapper() }, + ); + + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("should NOT call resumeSandbox when conversationStatus is undefined", () => { + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: undefined, + }), + { wrapper: createWrapper() }, + ); + + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("should only call resumeSandbox once per conversation on initial load", () => { + const { rerender } = renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "STOPPED", + }), + { wrapper: createWrapper() }, + ); + + expect(mockMutate).toHaveBeenCalledTimes(1); + + // Rerender with same props - should not trigger again + rerender(); + + expect(mockMutate).toHaveBeenCalledTimes(1); + }); + + it("should call resumeSandbox for a new conversation after navigating", async () => { + const { rerender } = renderHook( + ({ conversationId }) => + useSandboxRecovery({ + conversationId, + conversationStatus: "STOPPED", + }), + { + wrapper: createWrapper(), + initialProps: { conversationId: "conv-123" }, + }, + ); + + expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate).toHaveBeenLastCalledWith( + expect.objectContaining({ conversationId: "conv-123" }), + expect.any(Object), + ); + + // Navigate to a different conversation + rerender({ conversationId: "conv-456" }); + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledTimes(2); + }); + + expect(mockMutate).toHaveBeenLastCalledWith( + expect.objectContaining({ conversationId: "conv-456" }), + expect.any(Object), + ); + }); + }); + + describe("tab focus recovery", () => { + it("should call resumeSandbox when tab becomes visible and refetch returns STOPPED", async () => { + // Start with tab hidden + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + }); + + const mockRefetch = vi.fn().mockResolvedValue({ + data: { status: "STOPPED" }, + }); + + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "RUNNING", // Cached status is RUNNING + refetchConversation: mockRefetch, + }), + { wrapper: createWrapper() }, + ); + + // No initial recovery for RUNNING + expect(mockMutate).not.toHaveBeenCalled(); + + // Simulate tab becoming visible + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + }); + + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + // Refetch should be called to get fresh status + expect(mockRefetch).toHaveBeenCalledTimes(1); + // Recovery should trigger because fresh status is STOPPED + expect(mockMutate).toHaveBeenCalledTimes(1); + }); + + it("should NOT call resumeSandbox when tab becomes visible and refetch returns RUNNING", async () => { + const mockRefetch = vi.fn().mockResolvedValue({ + data: { status: "RUNNING" }, + }); + + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "RUNNING", + refetchConversation: mockRefetch, + }), + { wrapper: createWrapper() }, + ); + + // No initial recovery for RUNNING + expect(mockMutate).not.toHaveBeenCalled(); + + // Simulate tab becoming visible + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + // Refetch was called but status is still RUNNING + expect(mockRefetch).toHaveBeenCalledTimes(1); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("should NOT call resumeSandbox when tab becomes visible but refetchConversation is not provided", async () => { + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "STOPPED", + // No refetchConversation provided + }), + { wrapper: createWrapper() }, + ); + + // Initial load triggers recovery + expect(mockMutate).toHaveBeenCalledTimes(1); + mockMutate.mockClear(); + + // Simulate tab becoming visible + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + // No recovery on tab focus without refetchConversation + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("should NOT call resumeSandbox when tab becomes hidden", async () => { + const mockRefetch = vi.fn().mockResolvedValue({ + data: { status: "STOPPED" }, + }); + + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "STOPPED", + refetchConversation: mockRefetch, + }), + { wrapper: createWrapper() }, + ); + + // Initial load triggers recovery + expect(mockMutate).toHaveBeenCalledTimes(1); + mockMutate.mockClear(); + + // Simulate tab becoming hidden + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + }); + + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + // Refetch should NOT be called when tab is hidden + expect(mockRefetch).not.toHaveBeenCalled(); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("should clean up visibility event listener on unmount", () => { + const addEventListenerSpy = vi.spyOn(document, "addEventListener"); + const removeEventListenerSpy = vi.spyOn(document, "removeEventListener"); + + const { unmount } = renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "STOPPED", + }), + { wrapper: createWrapper() }, + ); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + "visibilitychange", + expect.any(Function), + ); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "visibilitychange", + expect.any(Function), + ); + }); + + it("should NOT call resumeSandbox when tab becomes visible while isPending is true", async () => { + vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: true, + isSuccess: false, + isError: false, + isIdle: false, + data: undefined, + error: null, + reset: vi.fn(), + status: "pending", + variables: undefined, + failureCount: 0, + failureReason: null, + submittedAt: 0, + context: undefined, + } as unknown as ReturnType); + + const mockRefetch = vi.fn().mockResolvedValue({ + data: { status: "STOPPED" }, + }); + + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "RUNNING", + refetchConversation: mockRefetch, + }), + { wrapper: createWrapper() }, + ); + + // Simulate tab becoming visible + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + // Refetch will be called when isPending is true + expect(mockRefetch).toHaveBeenCalledTimes(1); + // resumeSandbox should NOT be called + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it("should handle refetch errors gracefully without crashing", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const mockRefetch = vi.fn().mockRejectedValue(new Error("Network error")); + + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "RUNNING", + refetchConversation: mockRefetch, + }), + { wrapper: createWrapper() }, + ); + + // Simulate tab becoming visible + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + // Refetch was called + expect(mockRefetch).toHaveBeenCalledTimes(1); + // Error was logged + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Failed to refetch conversation on visibility change:", + expect.any(Error), + ); + // No recovery attempt was made (due to error) + expect(mockMutate).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe("recovery callbacks", () => { + it("should return isResuming=false when no recovery is in progress", () => { + const { result } = renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "RUNNING", + }), + { wrapper: createWrapper() }, + ); + + expect(result.current.isResuming).toBe(false); + }); + + it("should return isResuming=true when mutation is pending", () => { + vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: true, + isSuccess: false, + isError: false, + isIdle: false, + data: undefined, + error: null, + reset: vi.fn(), + status: "pending", + variables: undefined, + failureCount: 0, + failureReason: null, + submittedAt: 0, + context: undefined, + } as unknown as ReturnType); + + const { result } = renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "STOPPED", + }), + { wrapper: createWrapper() }, + ); + + expect(result.current.isResuming).toBe(true); + }); + + it("should call onSuccess callback when recovery succeeds", () => { + const onSuccess = vi.fn(); + + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "STOPPED", + onSuccess, + }), + { wrapper: createWrapper() }, + ); + + // Get the onSuccess callback passed to mutate + const mutateCall = mockMutate.mock.calls[0]; + const options = mutateCall[1]; + + // Simulate successful mutation + act(() => { + options.onSuccess(); + }); + + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it("should call onError callback and display toast when recovery fails", () => { + const onError = vi.fn(); + const testError = new Error("Resume failed"); + + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "STOPPED", + onError, + }), + { wrapper: createWrapper() }, + ); + + // Get the onError callback passed to mutate + const mutateCall = mockMutate.mock.calls[0]; + const options = mutateCall[1]; + + // Simulate failed mutation + act(() => { + options.onError(testError); + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(testError); + expect(vi.mocked(customToastHandlers.displayErrorToast)).toHaveBeenCalled(); + }); + + it("should NOT call resumeSandbox when isPending is true", () => { + vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({ + mutate: mockMutate, + mutateAsync: vi.fn(), + isPending: true, + isSuccess: false, + isError: false, + isIdle: false, + data: undefined, + error: null, + reset: vi.fn(), + status: "pending", + variables: undefined, + failureCount: 0, + failureReason: null, + submittedAt: 0, + context: undefined, + } as unknown as ReturnType); + + renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "STOPPED", + }), + { wrapper: createWrapper() }, + ); + + // Should not call mutate because isPending is true + expect(mockMutate).not.toHaveBeenCalled(); + }); + }); + + describe("WebSocket disconnect (negative test)", () => { + it("should NOT have any mechanism to auto-resume on WebSocket disconnect", () => { + // This test documents the intended behavior: the hook does NOT + // listen for WebSocket disconnects. Recovery only happens on: + // 1. Initial page load (STOPPED status) + // 2. Tab focus (visibilitychange event) + // + // There is intentionally NO onDisconnect handler or WebSocket listener. + + const { result } = renderHook( + () => + useSandboxRecovery({ + conversationId: "conv-123", + conversationStatus: "RUNNING", + }), + { wrapper: createWrapper() }, + ); + + // The hook should only expose isResuming - no disconnect-related functionality + expect(result.current).toEqual({ + isResuming: expect.any(Boolean), + }); + + // No calls should have been made for RUNNING status + expect(mockMutate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/__tests__/hooks/use-visibility-recovery.test.ts b/frontend/__tests__/hooks/use-visibility-recovery.test.ts new file mode 100644 index 0000000000..301d910fa2 --- /dev/null +++ b/frontend/__tests__/hooks/use-visibility-recovery.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useVisibilityChange } from "#/hooks/use-visibility-change"; + +describe("useVisibilityChange", () => { + beforeEach(() => { + // Reset document.visibilityState to visible + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("initial state", () => { + it("should return isVisible=true when document is visible", () => { + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + }); + + const { result } = renderHook(() => useVisibilityChange()); + + expect(result.current.isVisible).toBe(true); + }); + + it("should return isVisible=false when document is hidden", () => { + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + }); + + const { result } = renderHook(() => useVisibilityChange()); + + expect(result.current.isVisible).toBe(false); + }); + }); + + describe("visibility change events", () => { + it("should update isVisible when visibility changes to hidden", () => { + const { result } = renderHook(() => useVisibilityChange()); + + expect(result.current.isVisible).toBe(true); + + // Simulate tab becoming hidden + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(result.current.isVisible).toBe(false); + }); + + it("should update isVisible when visibility changes to visible", () => { + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + }); + + const { result } = renderHook(() => useVisibilityChange()); + + expect(result.current.isVisible).toBe(false); + + // Simulate tab becoming visible + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(result.current.isVisible).toBe(true); + }); + }); + + describe("callbacks", () => { + it("should call onVisibilityChange with the new state", () => { + const onVisibilityChange = vi.fn(); + + renderHook(() => useVisibilityChange({ onVisibilityChange })); + + // Simulate tab becoming hidden + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(onVisibilityChange).toHaveBeenCalledWith("hidden"); + + // Simulate tab becoming visible + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(onVisibilityChange).toHaveBeenCalledWith("visible"); + }); + + it("should call onVisible only when tab becomes visible", () => { + const onVisible = vi.fn(); + const onHidden = vi.fn(); + + renderHook(() => useVisibilityChange({ onVisible, onHidden })); + + // Simulate tab becoming hidden + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(onVisible).not.toHaveBeenCalled(); + expect(onHidden).toHaveBeenCalledTimes(1); + + // Simulate tab becoming visible + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(onVisible).toHaveBeenCalledTimes(1); + expect(onHidden).toHaveBeenCalledTimes(1); + }); + + it("should call onHidden only when tab becomes hidden", () => { + const onHidden = vi.fn(); + + renderHook(() => useVisibilityChange({ onHidden })); + + // Simulate tab becoming hidden + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(onHidden).toHaveBeenCalledTimes(1); + + // Simulate tab becoming visible (should not call onHidden) + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(onHidden).toHaveBeenCalledTimes(1); + }); + }); + + describe("enabled option", () => { + it("should not listen for events when enabled=false", () => { + const onVisible = vi.fn(); + + renderHook(() => useVisibilityChange({ onVisible, enabled: false })); + + // Simulate tab becoming visible + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(onVisible).not.toHaveBeenCalled(); + }); + + it("should start listening when enabled changes from false to true", () => { + const onVisible = vi.fn(); + + const { rerender } = renderHook( + ({ enabled }) => useVisibilityChange({ onVisible, enabled }), + { initialProps: { enabled: false } }, + ); + + // Simulate event while disabled + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(onVisible).not.toHaveBeenCalled(); + + // Enable the hook + rerender({ enabled: true }); + + // Now events should be captured + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(onVisible).toHaveBeenCalledTimes(1); + }); + }); + + describe("cleanup", () => { + it("should remove event listener on unmount", () => { + const addEventListenerSpy = vi.spyOn(document, "addEventListener"); + const removeEventListenerSpy = vi.spyOn(document, "removeEventListener"); + + const { unmount } = renderHook(() => useVisibilityChange()); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + "visibilitychange", + expect.any(Function), + ); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "visibilitychange", + expect.any(Function), + ); + }); + + it("should remove event listener when enabled changes to false", () => { + const removeEventListenerSpy = vi.spyOn(document, "removeEventListener"); + + const { rerender } = renderHook( + ({ enabled }) => useVisibilityChange({ enabled }), + { initialProps: { enabled: true } }, + ); + + rerender({ enabled: false }); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "visibilitychange", + expect.any(Function), + ); + }); + }); + + describe("callback stability", () => { + it("should handle callback updates without missing events", () => { + const onVisible1 = vi.fn(); + const onVisible2 = vi.fn(); + + const { rerender } = renderHook( + ({ onVisible }) => useVisibilityChange({ onVisible }), + { initialProps: { onVisible: onVisible1 } }, + ); + + // Update callback + rerender({ onVisible: onVisible2 }); + + // Simulate visibility change + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(onVisible1).not.toHaveBeenCalled(); + expect(onVisible2).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index 86863734b9..33c7169a77 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -78,7 +78,6 @@ export function ConversationWebSocketProvider({ sessionApiKey, subConversations, subConversationIds, - onDisconnect, }: { children: React.ReactNode; conversationId?: string; @@ -86,7 +85,6 @@ export function ConversationWebSocketProvider({ sessionApiKey?: string | null; subConversations?: V1AppConversation[]; subConversationIds?: string[]; - onDisconnect?: () => void; }) { // Separate connection state tracking for each WebSocket const [mainConnectionState, setMainConnectionState] = @@ -714,13 +712,10 @@ export function ConversationWebSocketProvider({ } } }, - onClose: (event: CloseEvent) => { + onClose: () => { setMainConnectionState("CLOSED"); - // Trigger silent recovery on unexpected disconnect - // Do NOT show error message - recovery happens automatically - if (event.code !== 1000 && hasConnectedRefMain.current) { - onDisconnect?.(); - } + // Recovery is handled by useSandboxRecovery on tab focus/page refresh + // No error message needed - silent recovery provides better UX }, onError: () => { setMainConnectionState("CLOSED"); @@ -738,7 +733,6 @@ export function ConversationWebSocketProvider({ sessionApiKey, conversationId, conversationUrl, - onDisconnect, ]); // Separate WebSocket options for planning agent connection @@ -785,13 +779,10 @@ export function ConversationWebSocketProvider({ } } }, - onClose: (event: CloseEvent) => { + onClose: () => { setPlanningConnectionState("CLOSED"); - // Trigger silent recovery on unexpected disconnect - // Do NOT show error message - recovery happens automatically - if (event.code !== 1000 && hasConnectedRefPlanning.current) { - onDisconnect?.(); - } + // Recovery is handled by useSandboxRecovery on tab focus/page refresh + // No error message needed - silent recovery provides better UX }, onError: () => { setPlanningConnectionState("CLOSED"); @@ -808,7 +799,6 @@ export function ConversationWebSocketProvider({ removeErrorMessage, sessionApiKey, subConversations, - onDisconnect, ]); // Only attempt WebSocket connection when we have a valid URL diff --git a/frontend/src/contexts/websocket-provider-wrapper.tsx b/frontend/src/contexts/websocket-provider-wrapper.tsx index 3aa21e4113..e484bd0571 100644 --- a/frontend/src/contexts/websocket-provider-wrapper.tsx +++ b/frontend/src/contexts/websocket-provider-wrapper.tsx @@ -3,7 +3,8 @@ import { WsClientProvider } from "#/context/ws-client-provider"; import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useSubConversations } from "#/hooks/query/use-sub-conversations"; -import { useWebSocketRecovery } from "#/hooks/use-websocket-recovery"; +import { useSandboxRecovery } from "#/hooks/use-sandbox-recovery"; +import { isTaskConversationId } from "#/utils/conversation-local-storage"; interface WebSocketProviderWrapperProps { children: React.ReactNode; @@ -18,18 +19,6 @@ interface WebSocketProviderWrapperProps { * @param version - 0 for old WsClientProvider, 1 for new ConversationWebSocketProvider * @param conversationId - The conversation ID to pass to the provider * @param children - The child components to wrap - * - * @example - * // Use the old v0 provider - * - * - * - * - * @example - * // Use the new v1 provider - * - * - * */ export function WebSocketProviderWrapper({ children, @@ -37,7 +26,11 @@ export function WebSocketProviderWrapper({ version, }: WebSocketProviderWrapperProps) { // Get conversation data for V1 provider - const { data: conversation } = useActiveConversation(); + const { + data: conversation, + refetch: refetchConversation, + isFetched, + } = useActiveConversation(); // Get sub-conversation data for V1 provider const { data: subConversations } = useSubConversations( conversation?.sub_conversation_ids ?? [], @@ -48,9 +41,15 @@ export function WebSocketProviderWrapper({ (subConversation) => subConversation !== null, ); - // Silent recovery for V1 WebSocket disconnections - const { reconnectKey, handleDisconnect } = - useWebSocketRecovery(conversationId); + const isConversationReady = + !isTaskConversationId(conversationId) && isFetched && !!conversation; + // Recovery for V1 conversations - handles page refresh and tab focus + // Does NOT resume on WebSocket disconnect (server pauses after 20 min inactivity) + useSandboxRecovery({ + conversationId, + conversationStatus: conversation?.status, + refetchConversation: isConversationReady ? refetchConversation : undefined, + }); if (version === 0) { return ( @@ -63,13 +62,11 @@ export function WebSocketProviderWrapper({ if (version === 1) { return ( {children} diff --git a/frontend/src/hooks/use-sandbox-recovery.ts b/frontend/src/hooks/use-sandbox-recovery.ts new file mode 100644 index 0000000000..78804f6706 --- /dev/null +++ b/frontend/src/hooks/use-sandbox-recovery.ts @@ -0,0 +1,138 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useUnifiedResumeConversationSandbox } from "./mutation/use-unified-start-conversation"; +import { useUserProviders } from "./use-user-providers"; +import { useVisibilityChange } from "./use-visibility-change"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { I18nKey } from "#/i18n/declaration"; +import type { ConversationStatus } from "#/types/conversation-status"; +import type { Conversation } from "#/api/open-hands.types"; + +interface UseSandboxRecoveryOptions { + conversationId: string | undefined; + conversationStatus: ConversationStatus | undefined; + /** Function to refetch the conversation data - used to get fresh status on tab focus */ + refetchConversation?: () => Promise<{ + data: Conversation | null | undefined; + }>; + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +/** + * Hook that handles sandbox recovery based on user intent. + * + * Recovery triggers: + * - Page refresh: Resumes the sandbox on initial load if it was paused/stopped + * - Tab gains focus: Resumes the sandbox if it was paused/stopped + * + * What does NOT trigger recovery: + * - WebSocket disconnect: Does NOT automatically resume the sandbox + * (The server pauses sandboxes after 20 minutes of inactivity, + * and sandboxes should only be resumed when the user explicitly shows intent) + * + * @param options.conversationId - The conversation ID to recover + * @param options.conversationStatus - The current conversation status + * @param options.refetchConversation - Function to refetch conversation data on tab focus + * @param options.onSuccess - Callback when recovery succeeds + * @param options.onError - Callback when recovery fails + * @returns isResuming - Whether a recovery is in progress + */ +export function useSandboxRecovery({ + conversationId, + conversationStatus, + refetchConversation, + onSuccess, + onError, +}: UseSandboxRecoveryOptions) { + const { t } = useTranslation(); + const { providers } = useUserProviders(); + const { mutate: resumeSandbox, isPending: isResuming } = + useUnifiedResumeConversationSandbox(); + + // Track which conversation ID we've already processed for initial load recovery + const processedConversationIdRef = React.useRef(null); + + const attemptRecovery = React.useCallback( + (statusOverride?: ConversationStatus) => { + const status = statusOverride ?? conversationStatus; + /** + * Only recover if sandbox is paused (status === STOPPED) and not already resuming + * + * Note: ConversationStatus uses different terminology than SandboxStatus: + * - SandboxStatus.PAUSED → ConversationStatus.STOPPED : the runtime is not running but may be restarted + * - SandboxStatus.MISSING → ConversationStatus.ARCHIVED : the runtime is not running and will not restart due to deleted files. + */ + if (!conversationId || status !== "STOPPED" || isResuming) { + return; + } + + resumeSandbox( + { conversationId, providers }, + { + onSuccess: () => { + onSuccess?.(); + }, + onError: (error) => { + displayErrorToast( + t(I18nKey.CONVERSATION$FAILED_TO_START_WITH_ERROR, { + error: error.message, + }), + ); + onError?.(error); + }, + }, + ); + }, + [ + conversationId, + conversationStatus, + isResuming, + providers, + resumeSandbox, + onSuccess, + onError, + t, + ], + ); + + // Handle page refresh (initial load) and conversation navigation + React.useEffect(() => { + if (!conversationId || !conversationStatus) return; + + // Only attempt recovery once per conversation (handles both initial load and navigation) + if (processedConversationIdRef.current === conversationId) return; + + processedConversationIdRef.current = conversationId; + + if (conversationStatus === "STOPPED") { + attemptRecovery(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conversationId, conversationStatus]); + + const handleVisible = React.useCallback(async () => { + // Skip if no conversation or refetch function + if (!conversationId || !refetchConversation) return; + + try { + // Refetch to get fresh status - cached status may be stale if sandbox was paused while tab was inactive + const { data } = await refetchConversation(); + attemptRecovery(data?.status); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + "Failed to refetch conversation on visibility change:", + error, + ); + } + }, [conversationId, refetchConversation, isResuming, attemptRecovery]); + + // Handle tab focus (visibility change) - refetch conversation status and resume if needed + useVisibilityChange({ + enabled: !!conversationId, + onVisible: handleVisible, + }); + + return { isResuming }; +} diff --git a/frontend/src/hooks/use-visibility-change.ts b/frontend/src/hooks/use-visibility-change.ts new file mode 100644 index 0000000000..1ee929cf6c --- /dev/null +++ b/frontend/src/hooks/use-visibility-change.ts @@ -0,0 +1,64 @@ +import React from "react"; + +type VisibilityState = "visible" | "hidden"; + +interface UseVisibilityChangeOptions { + /** Callback fired when visibility changes to the specified state */ + onVisibilityChange?: (state: VisibilityState) => void; + /** Callback fired only when tab becomes visible */ + onVisible?: () => void; + /** Callback fired only when tab becomes hidden */ + onHidden?: () => void; + /** Whether to listen for visibility changes (default: true) */ + enabled?: boolean; +} + +/** + * Hook that listens for browser tab visibility changes. + * + * Useful for: + * - Resuming operations when user returns to the tab + * - Pausing expensive operations when tab is hidden + * - Tracking user engagement + * + * @param options.onVisibilityChange - Callback with the new visibility state + * @param options.onVisible - Callback fired only when tab becomes visible + * @param options.onHidden - Callback fired only when tab becomes hidden + * @param options.enabled - Whether to listen for changes (default: true) + * @returns isVisible - Current visibility state of the tab + */ +export function useVisibilityChange({ + onVisibilityChange, + onVisible, + onHidden, + enabled = true, +}: UseVisibilityChangeOptions = {}) { + const [isVisible, setIsVisible] = React.useState( + () => document.visibilityState === "visible", + ); + + React.useEffect(() => { + if (!enabled) return undefined; + + const handleVisibilityChange = () => { + const state = document.visibilityState as VisibilityState; + setIsVisible(state === "visible"); + + onVisibilityChange?.(state); + + if (state === "visible") { + onVisible?.(); + } else { + onHidden?.(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [enabled, onVisibilityChange, onVisible, onHidden]); + + return { isVisible }; +} diff --git a/frontend/src/hooks/use-websocket-recovery.ts b/frontend/src/hooks/use-websocket-recovery.ts deleted file mode 100644 index d15358d12e..0000000000 --- a/frontend/src/hooks/use-websocket-recovery.ts +++ /dev/null @@ -1,110 +0,0 @@ -import React from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation"; -import { useUserProviders } from "#/hooks/use-user-providers"; -import { useErrorMessageStore } from "#/stores/error-message-store"; -import { I18nKey } from "#/i18n/declaration"; - -const MAX_RECOVERY_ATTEMPTS = 3; -const RECOVERY_COOLDOWN_MS = 5000; -const RECOVERY_SETTLED_DELAY_MS = 2000; - -/** - * Hook that handles silent WebSocket recovery by resuming the sandbox - * when a WebSocket disconnection is detected. - * - * @param conversationId - The conversation ID to recover - * @returns reconnectKey - Key to force provider remount (resets connection state) - * @returns handleDisconnect - Callback to trigger recovery on WebSocket disconnect - */ -export function useWebSocketRecovery(conversationId: string) { - // Recovery state (refs to avoid re-renders) - const recoveryAttemptsRef = React.useRef(0); - const recoveryInProgressRef = React.useRef(false); - const lastRecoveryAttemptRef = React.useRef(null); - - // Key to force remount of provider after recovery (resets connection state to "CONNECTING") - const [reconnectKey, setReconnectKey] = React.useState(0); - - const queryClient = useQueryClient(); - const { mutate: resumeConversation } = useUnifiedResumeConversationSandbox(); - const { providers } = useUserProviders(); - const setErrorMessage = useErrorMessageStore( - (state) => state.setErrorMessage, - ); - - // Reset recovery state when conversation changes - React.useEffect(() => { - recoveryAttemptsRef.current = 0; - recoveryInProgressRef.current = false; - lastRecoveryAttemptRef.current = null; - }, [conversationId]); - - // Silent recovery callback - resumes sandbox when WebSocket disconnects - const handleDisconnect = React.useCallback(() => { - // Prevent concurrent recovery attempts - if (recoveryInProgressRef.current) return; - - // Check cooldown - const now = Date.now(); - if ( - lastRecoveryAttemptRef.current && - now - lastRecoveryAttemptRef.current < RECOVERY_COOLDOWN_MS - ) { - return; - } - - // Check max attempts - notify user when recovery is exhausted - if (recoveryAttemptsRef.current >= MAX_RECOVERY_ATTEMPTS) { - setErrorMessage(I18nKey.STATUS$CONNECTION_LOST); - return; - } - - // Start silent recovery - recoveryInProgressRef.current = true; - lastRecoveryAttemptRef.current = now; - recoveryAttemptsRef.current += 1; - - resumeConversation( - { conversationId, providers }, - { - onSuccess: async () => { - // Invalidate and wait for refetch to complete before remounting - // This ensures the provider remounts with fresh data (url: null during startup) - await queryClient.invalidateQueries({ - queryKey: ["user", "conversation", conversationId], - }); - - // Force remount to reset connection state to "CONNECTING" - setReconnectKey((k) => k + 1); - - // Reset recovery state on success - recoveryAttemptsRef.current = 0; - recoveryInProgressRef.current = false; - lastRecoveryAttemptRef.current = null; - }, - onError: () => { - // If this was the last attempt, show error to user - if (recoveryAttemptsRef.current >= MAX_RECOVERY_ATTEMPTS) { - setErrorMessage(I18nKey.STATUS$CONNECTION_LOST); - } - // recoveryInProgressRef will be reset by onSettled - }, - onSettled: () => { - // Allow next attempt after a delay (covers both success and error) - setTimeout(() => { - recoveryInProgressRef.current = false; - }, RECOVERY_SETTLED_DELAY_MS); - }, - }, - ); - }, [ - conversationId, - providers, - resumeConversation, - queryClient, - setErrorMessage, - ]); - - return { reconnectKey, handleDisconnect }; -} diff --git a/frontend/src/routes/conversation.tsx b/frontend/src/routes/conversation.tsx index 3063c2d89c..c12ce948c7 100644 --- a/frontend/src/routes/conversation.tsx +++ b/frontend/src/routes/conversation.tsx @@ -18,7 +18,6 @@ import { useTaskPolling } from "#/hooks/query/use-task-polling"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; import { useIsAuthed } from "#/hooks/query/use-is-authed"; import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider"; -import { useUserProviders } from "#/hooks/use-user-providers"; import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main"; import { ConversationNameWithStatus } from "#/components/features/conversation/conversation-name-with-status"; @@ -26,7 +25,6 @@ import { ConversationNameWithStatus } from "#/components/features/conversation/c import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs"; import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper"; import { useErrorMessageStore } from "#/stores/error-message-store"; -import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation"; import { I18nKey } from "#/i18n/declaration"; import { useEventStore } from "#/stores/use-event-store"; @@ -39,11 +37,8 @@ function AppContent() { // Handle both task IDs (task-{uuid}) and regular conversation IDs const { isTask, taskStatus, taskDetail } = useTaskPolling(); - const { data: conversation, isFetched, refetch } = useActiveConversation(); - const { mutate: startConversation, isPending: isStarting } = - useUnifiedResumeConversationSandbox(); + const { data: conversation, isFetched } = useActiveConversation(); const { data: isAuthed } = useIsAuthed(); - const { providers } = useUserProviders(); const { resetConversationState } = useConversationStore(); const navigate = useNavigate(); const clearTerminal = useCommandStore((state) => state.clearTerminal); @@ -54,9 +49,6 @@ function AppContent() { (state) => state.removeErrorMessage, ); - // Track which conversation ID we've auto-started to prevent auto-restart after manual stop - const processedConversationId = React.useRef(null); - // Fetch batch feedback data when conversation is loaded useBatchFeedback(); @@ -67,12 +59,6 @@ function AppContent() { setCurrentAgentState(AgentState.LOADING); removeErrorMessage(); clearEvents(); - - // Reset tracking ONLY if we're navigating to a DIFFERENT conversation - // Don't reset on StrictMode remounts (conversationId is the same) - if (processedConversationId.current !== conversationId) { - processedConversationId.current = null; - } }, [ conversationId, clearTerminal, @@ -91,7 +77,8 @@ function AppContent() { } }, [isTask, taskStatus, taskDetail, t]); - // 3. Auto-start Effect - handles conversation not found and auto-starting STOPPED conversations + // 3. Handle conversation not found + // NOTE: Resuming STOPPED conversations is handled by useSandboxRecovery in WebSocketProviderWrapper React.useEffect(() => { // Wait for data to be fetched if (!isFetched || !isAuthed) return; @@ -100,50 +87,8 @@ function AppContent() { if (!conversation) { displayErrorToast(t(I18nKey.CONVERSATION$NOT_EXIST_OR_NO_PERMISSION)); navigate("/"); - return; } - - const currentConversationId = conversation.conversation_id; - const currentStatus = conversation.status; - - // Skip if we've already processed this conversation - if (processedConversationId.current === currentConversationId) { - return; - } - - // Mark as processed immediately to prevent duplicate calls - processedConversationId.current = currentConversationId; - - // Auto-start STOPPED conversations on initial load only - if (currentStatus === "STOPPED" && !isStarting) { - startConversation( - { conversationId: currentConversationId, providers }, - { - onError: (error) => { - displayErrorToast( - t(I18nKey.CONVERSATION$FAILED_TO_START_WITH_ERROR, { - error: error.message, - }), - ); - refetch(); - }, - }, - ); - } - // NOTE: conversation?.status is intentionally NOT in dependencies - // We only want to run when conversation ID changes, not when status changes - // This prevents duplicate calls when stale cache data is replaced with fresh data - }, [ - conversation?.conversation_id, - isFetched, - isAuthed, - isStarting, - providers, - startConversation, - navigate, - refetch, - t, - ]); + }, [conversation, isFetched, isAuthed, navigate, t]); const isV0Conversation = conversation?.conversation_version === "V0"; From 00daaa41d327435f61a7e93e70debfa8d75f8650 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Tue, 17 Mar 2026 00:55:23 +0800 Subject: [PATCH 33/92] feat: Load workspace hooks for V1 conversations and add hooks viewer UI (#12773) Co-authored-by: openhands Co-authored-by: enyst Co-authored-by: Alona King --- .../context-menu/tools-context-menu.test.tsx | 1 + .../conversation-panel/hooks-modal.test.tsx | 207 +++++++++ .../v1-conversation-service.api.ts | 13 + .../v1-conversation-service.types.ts | 21 + .../hook-execution-event-message.tsx | 1 + .../chat/event-message-components/index.ts | 1 + .../features/controls/tools-context-menu.tsx | 19 + .../components/features/controls/tools.tsx | 12 + .../conversation-panel/hook-event-item.tsx | 75 ++++ .../hook-matcher-content.tsx | 61 +++ .../conversation-panel/hooks-empty-state.tsx | 21 + .../hooks-loading-state.tsx | 7 + .../conversation-panel/hooks-modal-header.tsx | 45 ++ .../conversation-panel/hooks-modal.tsx | 102 +++++ .../conversation-name-context-menu.tsx | 18 +- .../conversation/conversation-name.tsx | 11 + .../shared/hook-execution-event-message.tsx | 152 +++++++ .../should-render-event.ts | 6 + .../hook-execution-event-message.tsx | 1 + .../v1/chat/event-message-components/index.ts | 1 + .../src/components/v1/chat/event-message.tsx | 7 + .../src/hooks/query/use-conversation-hooks.ts | 36 ++ .../use-conversation-name-context-menu.ts | 16 + frontend/src/i18n/declaration.ts | 24 ++ frontend/src/i18n/translation.json | 392 +++++++++++++++++- frontend/src/types/core/base.ts | 2 +- frontend/src/types/v1/core/base/common.ts | 2 +- .../v1/core/events/hook-execution-event.ts | 100 +++++ frontend/src/types/v1/core/events/index.ts | 1 + frontend/src/types/v1/core/openhands-event.ts | 3 + frontend/src/types/v1/type-guards.ts | 12 +- .../app_conversation_models.py | 29 ++ .../app_conversation_router.py | 343 +++++++++++---- .../app_conversation/hook_loader.py | 148 +++++++ .../live_status_app_conversation_service.py | 74 ++++ .../test_app_conversation_hooks_endpoint.py | 293 +++++++++++++ ...st_live_status_app_conversation_service.py | 279 +++++++++++++ 37 files changed, 2452 insertions(+), 84 deletions(-) create mode 100644 frontend/__tests__/components/features/conversation-panel/hooks-modal.test.tsx create mode 100644 frontend/src/components/features/chat/event-message-components/hook-execution-event-message.tsx create mode 100644 frontend/src/components/features/conversation-panel/hook-event-item.tsx create mode 100644 frontend/src/components/features/conversation-panel/hook-matcher-content.tsx create mode 100644 frontend/src/components/features/conversation-panel/hooks-empty-state.tsx create mode 100644 frontend/src/components/features/conversation-panel/hooks-loading-state.tsx create mode 100644 frontend/src/components/features/conversation-panel/hooks-modal-header.tsx create mode 100644 frontend/src/components/features/conversation-panel/hooks-modal.tsx create mode 100644 frontend/src/components/shared/hook-execution-event-message.tsx create mode 100644 frontend/src/components/v1/chat/event-message-components/hook-execution-event-message.tsx create mode 100644 frontend/src/hooks/query/use-conversation-hooks.ts create mode 100644 frontend/src/types/v1/core/events/hook-execution-event.ts create mode 100644 openhands/app_server/app_conversation/hook_loader.py create mode 100644 tests/unit/app_server/test_app_conversation_hooks_endpoint.py diff --git a/frontend/__tests__/components/context-menu/tools-context-menu.test.tsx b/frontend/__tests__/components/context-menu/tools-context-menu.test.tsx index 3e4f4b90b1..7377febdf5 100644 --- a/frontend/__tests__/components/context-menu/tools-context-menu.test.tsx +++ b/frontend/__tests__/components/context-menu/tools-context-menu.test.tsx @@ -44,6 +44,7 @@ describe("SystemMessage UI Rendering", () => { {}} onShowSkills={() => {}} + onShowHooks={() => {}} onShowAgentTools={() => {}} />, ); diff --git a/frontend/__tests__/components/features/conversation-panel/hooks-modal.test.tsx b/frontend/__tests__/components/features/conversation-panel/hooks-modal.test.tsx new file mode 100644 index 0000000000..7cb788068d --- /dev/null +++ b/frontend/__tests__/components/features/conversation-panel/hooks-modal.test.tsx @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { HookEventItem } from "#/components/features/conversation-panel/hook-event-item"; +import { HooksEmptyState } from "#/components/features/conversation-panel/hooks-empty-state"; +import { HooksLoadingState } from "#/components/features/conversation-panel/hooks-loading-state"; +import { HooksModalHeader } from "#/components/features/conversation-panel/hooks-modal-header"; +import { HookEvent } from "#/api/conversation-service/v1-conversation-service.types"; + +// Mock react-i18next +vi.mock("react-i18next", async () => { + const actual = await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, params?: Record) => { + const translations: Record = { + HOOKS_MODAL$TITLE: "Available Hooks", + HOOKS_MODAL$HOOK_COUNT: `${params?.count ?? 0} hooks`, + HOOKS_MODAL$EVENT_PRE_TOOL_USE: "Pre Tool Use", + HOOKS_MODAL$EVENT_POST_TOOL_USE: "Post Tool Use", + HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT: "User Prompt Submit", + HOOKS_MODAL$EVENT_SESSION_START: "Session Start", + HOOKS_MODAL$EVENT_SESSION_END: "Session End", + HOOKS_MODAL$EVENT_STOP: "Stop", + HOOKS_MODAL$MATCHER: "Matcher", + HOOKS_MODAL$COMMANDS: "Commands", + HOOKS_MODAL$TYPE: `Type: ${params?.type ?? ""}`, + HOOKS_MODAL$TIMEOUT: `Timeout: ${params?.timeout ?? 0}s`, + HOOKS_MODAL$ASYNC: "Async", + COMMON$FETCH_ERROR: "Failed to fetch data", + CONVERSATION$NO_HOOKS: "No hooks configured", + BUTTON$REFRESH: "Refresh", + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + }), + }; +}); + +describe("HooksLoadingState", () => { + it("should render loading spinner", () => { + render(); + const spinner = document.querySelector(".animate-spin"); + expect(spinner).toBeInTheDocument(); + }); +}); + +describe("HooksEmptyState", () => { + it("should render no hooks message when not error", () => { + render(); + expect(screen.getByText("No hooks configured")).toBeInTheDocument(); + }); + + it("should render error message when isError is true", () => { + render(); + expect(screen.getByText("Failed to fetch data")).toBeInTheDocument(); + }); +}); + +describe("HooksModalHeader", () => { + const defaultProps = { + isAgentReady: true, + isLoading: false, + isRefetching: false, + onRefresh: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render title", () => { + render(); + expect(screen.getByText("Available Hooks")).toBeInTheDocument(); + }); + + it("should render refresh button when agent is ready", () => { + render(); + expect(screen.getByTestId("refresh-hooks")).toBeInTheDocument(); + }); + + it("should not render refresh button when agent is not ready", () => { + render(); + expect(screen.queryByTestId("refresh-hooks")).not.toBeInTheDocument(); + }); + + it("should call onRefresh when refresh button is clicked", async () => { + const user = userEvent.setup(); + const onRefresh = vi.fn(); + render(); + + await user.click(screen.getByTestId("refresh-hooks")); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it("should disable refresh button when loading", () => { + render(); + expect(screen.getByTestId("refresh-hooks")).toBeDisabled(); + }); + + it("should disable refresh button when refetching", () => { + render(); + expect(screen.getByTestId("refresh-hooks")).toBeDisabled(); + }); +}); + +describe("HookEventItem", () => { + const mockHookEvent: HookEvent = { + event_type: "stop", + matchers: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: ".openhands/hooks/on_stop.sh", + timeout: 30, + async: true, + }, + ], + }, + ], + }; + + const defaultProps = { + hookEvent: mockHookEvent, + isExpanded: false, + onToggle: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render event type label using i18n", () => { + render(); + expect(screen.getByText("Stop")).toBeInTheDocument(); + }); + + it("should render hook count", () => { + render(); + expect(screen.getByText("1 hooks")).toBeInTheDocument(); + }); + + it("should call onToggle when clicked", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + render(); + + await user.click(screen.getByRole("button")); + expect(onToggle).toHaveBeenCalledWith("stop"); + }); + + it("should show collapsed state by default", () => { + render(); + // Matcher content should not be visible when collapsed + expect(screen.queryByText("*")).not.toBeInTheDocument(); + }); + + it("should show expanded state with matcher content", () => { + render(); + // Matcher content should be visible when expanded + expect(screen.getByText("*")).toBeInTheDocument(); + }); + + it("should render async badge for async hooks", () => { + render(); + expect(screen.getByText("Async")).toBeInTheDocument(); + }); + + it("should render different event types with correct i18n labels", () => { + const eventTypes = [ + { type: "pre_tool_use", label: "Pre Tool Use" }, + { type: "post_tool_use", label: "Post Tool Use" }, + { type: "user_prompt_submit", label: "User Prompt Submit" }, + { type: "session_start", label: "Session Start" }, + { type: "session_end", label: "Session End" }, + { type: "stop", label: "Stop" }, + ]; + + eventTypes.forEach(({ type, label }) => { + const { unmount } = render( + , + ); + expect(screen.getByText(label)).toBeInTheDocument(); + unmount(); + }); + }); + + it("should fallback to event_type when no i18n key exists", () => { + render( + , + ); + expect(screen.getByText("unknown_event")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index 30fdeb9369..a0e99abe0f 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -14,6 +14,7 @@ import type { V1AppConversation, V1AppConversationPage, GetSkillsResponse, + GetHooksResponse, V1RuntimeConversationInfo, } from "./v1-conversation-service.types"; @@ -400,6 +401,18 @@ class V1ConversationService { return data; } + /** + * Get all hooks associated with a V1 conversation + * @param conversationId The conversation ID + * @returns The available hooks associated with the conversation + */ + static async getHooks(conversationId: string): Promise { + const { data } = await openHands.get( + `/api/v1/app-conversations/${conversationId}/hooks`, + ); + return data; + } + /** * Get conversation info directly from the runtime for a V1 conversation * Uses the custom runtime URL from the conversation diff --git a/frontend/src/api/conversation-service/v1-conversation-service.types.ts b/frontend/src/api/conversation-service/v1-conversation-service.types.ts index b437e17bf1..50e904d553 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.types.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.types.ts @@ -135,6 +135,27 @@ export interface GetSkillsResponse { skills: Skill[]; } +export interface HookDefinition { + type: string; // 'command' or 'prompt' + command: string; + timeout: number; + async?: boolean; +} + +export interface HookMatcher { + matcher: string; // Pattern: '*', exact match, or regex + hooks: HookDefinition[]; +} + +export interface HookEvent { + event_type: string; // e.g., 'stop', 'pre_tool_use', 'post_tool_use' + matchers: HookMatcher[]; +} + +export interface GetHooksResponse { + hooks: HookEvent[]; +} + // Runtime conversation types (from agent server) export interface V1RuntimeConversationStats { usage_to_metrics: Record; diff --git a/frontend/src/components/features/chat/event-message-components/hook-execution-event-message.tsx b/frontend/src/components/features/chat/event-message-components/hook-execution-event-message.tsx new file mode 100644 index 0000000000..4bec021ab7 --- /dev/null +++ b/frontend/src/components/features/chat/event-message-components/hook-execution-event-message.tsx @@ -0,0 +1 @@ +export { HookExecutionEventMessage } from "#/components/shared/hook-execution-event-message"; diff --git a/frontend/src/components/features/chat/event-message-components/index.ts b/frontend/src/components/features/chat/event-message-components/index.ts index c2db5a5f5a..d439405e5d 100644 --- a/frontend/src/components/features/chat/event-message-components/index.ts +++ b/frontend/src/components/features/chat/event-message-components/index.ts @@ -8,3 +8,4 @@ export { ObservationPairEventMessage } from "./observation-pair-event-message"; export { GenericEventMessageWrapper } from "./generic-event-message-wrapper"; export { MicroagentStatusWrapper } from "./microagent-status-wrapper"; export { LikertScaleWrapper } from "./likert-scale-wrapper"; +export { HookExecutionEventMessage } from "./hook-execution-event-message"; diff --git a/frontend/src/components/features/controls/tools-context-menu.tsx b/frontend/src/components/features/controls/tools-context-menu.tsx index 2089f95111..31d61105fe 100644 --- a/frontend/src/components/features/controls/tools-context-menu.tsx +++ b/frontend/src/components/features/controls/tools-context-menu.tsx @@ -27,15 +27,19 @@ const contextMenuListItemClassName = cn( interface ToolsContextMenuProps { onClose: () => void; onShowSkills: (event: React.MouseEvent) => void; + onShowHooks: (event: React.MouseEvent) => void; onShowAgentTools: (event: React.MouseEvent) => void; shouldShowAgentTools?: boolean; + shouldShowHooks?: boolean; } export function ToolsContextMenu({ onClose, onShowSkills, + onShowHooks, onShowAgentTools, shouldShowAgentTools = true, + shouldShowHooks = false, }: ToolsContextMenuProps) { const { t } = useTranslation(); const { data: conversation } = useActiveConversation(); @@ -141,6 +145,21 @@ export function ToolsContextMenu({ /> + {/* Show Hooks - Only show for V1 conversations */} + {shouldShowHooks && ( + + } + text={t(I18nKey.CONVERSATION$SHOW_HOOKS)} + className={CONTEXT_MENU_ICON_TEXT_CLASSNAME} + /> + + )} + {/* Show Agent Tools and Metadata - Only show if system message is available */} {shouldShowAgentTools && ( setContextMenuOpen(false)} onShowSkills={handleShowSkills} + onShowHooks={handleShowHooks} onShowAgentTools={handleShowAgentTools} shouldShowAgentTools={shouldShowAgentTools} + shouldShowHooks={shouldShowHooks} /> )} @@ -68,6 +75,11 @@ export function Tools() { {skillsModalVisible && ( setSkillsModalVisible(false)} /> )} + + {/* Hooks Modal */} + {hooksModalVisible && ( + setHooksModalVisible(false)} /> + )}
); } diff --git a/frontend/src/components/features/conversation-panel/hook-event-item.tsx b/frontend/src/components/features/conversation-panel/hook-event-item.tsx new file mode 100644 index 0000000000..add99f891b --- /dev/null +++ b/frontend/src/components/features/conversation-panel/hook-event-item.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from "react-i18next"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Typography } from "#/ui/typography"; +import { HookEvent } from "#/api/conversation-service/v1-conversation-service.types"; +import { HookMatcherContent } from "./hook-matcher-content"; +import { I18nKey } from "#/i18n/declaration"; + +interface HookEventItemProps { + hookEvent: HookEvent; + isExpanded: boolean; + onToggle: (eventType: string) => void; +} + +const EVENT_TYPE_I18N_KEYS: Record = { + pre_tool_use: I18nKey.HOOKS_MODAL$EVENT_PRE_TOOL_USE, + post_tool_use: I18nKey.HOOKS_MODAL$EVENT_POST_TOOL_USE, + user_prompt_submit: I18nKey.HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT, + session_start: I18nKey.HOOKS_MODAL$EVENT_SESSION_START, + session_end: I18nKey.HOOKS_MODAL$EVENT_SESSION_END, + stop: I18nKey.HOOKS_MODAL$EVENT_STOP, +}; + +export function HookEventItem({ + hookEvent, + isExpanded, + onToggle, +}: HookEventItemProps) { + const { t } = useTranslation(); + const i18nKey = EVENT_TYPE_I18N_KEYS[hookEvent.event_type]; + const eventTypeLabel = i18nKey ? t(i18nKey) : hookEvent.event_type; + + const totalHooks = hookEvent.matchers.reduce( + (sum, matcher) => sum + matcher.hooks.length, + 0, + ); + + return ( +
+ + + {isExpanded && ( +
+ {hookEvent.matchers.map((matcher, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/hook-matcher-content.tsx b/frontend/src/components/features/conversation-panel/hook-matcher-content.tsx new file mode 100644 index 0000000000..587653502f --- /dev/null +++ b/frontend/src/components/features/conversation-panel/hook-matcher-content.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; +import { Pre } from "#/ui/pre"; +import { HookMatcher } from "#/api/conversation-service/v1-conversation-service.types"; + +interface HookMatcherContentProps { + matcher: HookMatcher; +} + +export function HookMatcherContent({ matcher }: HookMatcherContentProps) { + const { t } = useTranslation(); + + return ( +
+
+ + {t(I18nKey.HOOKS_MODAL$MATCHER)} + + + {matcher.matcher} + +
+ +
+ + {t(I18nKey.HOOKS_MODAL$COMMANDS)} + + {matcher.hooks.map((hook, index) => ( +
+
+              {hook.command}
+            
+
+ {t(I18nKey.HOOKS_MODAL$TYPE, { type: hook.type })} + + {t(I18nKey.HOOKS_MODAL$TIMEOUT, { timeout: hook.timeout })} + + {hook.async ? ( + + {t(I18nKey.HOOKS_MODAL$ASYNC)} + + ) : null} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/hooks-empty-state.tsx b/frontend/src/components/features/conversation-panel/hooks-empty-state.tsx new file mode 100644 index 0000000000..626561b52f --- /dev/null +++ b/frontend/src/components/features/conversation-panel/hooks-empty-state.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; + +interface HooksEmptyStateProps { + isError: boolean; +} + +export function HooksEmptyState({ isError }: HooksEmptyStateProps) { + const { t } = useTranslation(); + + return ( +
+ + {isError + ? t(I18nKey.COMMON$FETCH_ERROR) + : t(I18nKey.CONVERSATION$NO_HOOKS)} + +
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/hooks-loading-state.tsx b/frontend/src/components/features/conversation-panel/hooks-loading-state.tsx new file mode 100644 index 0000000000..2a915a3677 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/hooks-loading-state.tsx @@ -0,0 +1,7 @@ +export function HooksLoadingState() { + return ( +
+
+
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/hooks-modal-header.tsx b/frontend/src/components/features/conversation-panel/hooks-modal-header.tsx new file mode 100644 index 0000000000..ab65fa7386 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/hooks-modal-header.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { RefreshCw } from "lucide-react"; +import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "../settings/brand-button"; + +interface HooksModalHeaderProps { + isAgentReady: boolean; + isLoading: boolean; + isRefetching: boolean; + onRefresh: () => void; +} + +export function HooksModalHeader({ + isAgentReady, + isLoading, + isRefetching, + onRefresh, +}: HooksModalHeaderProps) { + const { t } = useTranslation(); + + return ( +
+
+ + {isAgentReady && ( + + + {t(I18nKey.BUTTON$REFRESH)} + + )} +
+
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/hooks-modal.tsx b/frontend/src/components/features/conversation-panel/hooks-modal.tsx new file mode 100644 index 0000000000..6f2677ffb1 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/hooks-modal.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { I18nKey } from "#/i18n/declaration"; +import { useConversationHooks } from "#/hooks/query/use-conversation-hooks"; +import { AgentState } from "#/types/agent-state"; +import { Typography } from "#/ui/typography"; +import { HooksModalHeader } from "./hooks-modal-header"; +import { HooksLoadingState } from "./hooks-loading-state"; +import { HooksEmptyState } from "./hooks-empty-state"; +import { HookEventItem } from "./hook-event-item"; +import { useAgentState } from "#/hooks/use-agent-state"; + +interface HooksModalProps { + onClose: () => void; +} + +export function HooksModal({ onClose }: HooksModalProps) { + const { t } = useTranslation(); + const { curAgentState } = useAgentState(); + const [expandedEvents, setExpandedEvents] = useState>( + {}, + ); + const { + data: hooks, + isLoading, + isError, + refetch, + isRefetching, + } = useConversationHooks(); + + const toggleEvent = (eventType: string) => { + setExpandedEvents((prev) => ({ + ...prev, + [eventType]: !prev[eventType], + })); + }; + + const isAgentReady = ![AgentState.LOADING, AgentState.INIT].includes( + curAgentState, + ); + + return ( + + + + + {isAgentReady && ( + + {t(I18nKey.HOOKS_MODAL$WARNING)} + + )} + +
+ {!isAgentReady && ( +
+ + {t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)} + +
+ )} + + {isLoading && } + + {!isLoading && + isAgentReady && + (isError || !hooks || hooks.length === 0) && ( + + )} + + {!isLoading && isAgentReady && hooks && hooks.length > 0 && ( +
+ {hooks.map((hookEvent) => { + const isExpanded = + expandedEvents[hookEvent.event_type] || false; + + return ( + + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/features/conversation/conversation-name-context-menu.tsx b/frontend/src/components/features/conversation/conversation-name-context-menu.tsx index 1d1a7bb789..83d80eff39 100644 --- a/frontend/src/components/features/conversation/conversation-name-context-menu.tsx +++ b/frontend/src/components/features/conversation/conversation-name-context-menu.tsx @@ -35,6 +35,7 @@ interface ConversationNameContextMenuProps { onDisplayCost?: (event: React.MouseEvent) => void; onShowAgentTools?: (event: React.MouseEvent) => void; onShowSkills?: (event: React.MouseEvent) => void; + onShowHooks?: (event: React.MouseEvent) => void; onExportConversation?: (event: React.MouseEvent) => void; onDownloadViaVSCode?: (event: React.MouseEvent) => void; onTogglePublic?: (event: React.MouseEvent) => void; @@ -52,6 +53,7 @@ export function ConversationNameContextMenu({ onDisplayCost, onShowAgentTools, onShowSkills, + onShowHooks, onExportConversation, onDownloadViaVSCode, onTogglePublic, @@ -77,7 +79,7 @@ export function ConversationNameContextMenu({ const hasDownload = Boolean(onDownloadViaVSCode || onDownloadConversation); const hasExport = Boolean(onExportConversation); - const hasTools = Boolean(onShowAgentTools || onShowSkills); + const hasTools = Boolean(onShowAgentTools || onShowSkills || onShowHooks); const hasInfo = Boolean(onDisplayCost); const hasControl = Boolean(onStop || onDelete); @@ -119,6 +121,20 @@ export function ConversationNameContextMenu({ )} + {onShowHooks && ( + + } + text={t(I18nKey.CONVERSATION$SHOW_HOOKS)} + className={CONTEXT_MENU_ICON_TEXT_CLASSNAME} + /> + + )} + {onShowAgentTools && ( setSkillsModalVisible(false)} /> )} + {/* Hooks Modal */} + {hooksModalVisible && ( + setHooksModalVisible(false)} /> + )} + {/* Confirm Delete Modal */} {confirmDeleteModalVisible && ( 80) { + return `${command.slice(0, 77)}...`; + } + return command; +} + +function getStatusText(blocked: boolean, success: boolean): string { + if (blocked) return "blocked"; + if (success) return "ok"; + return "failed"; +} + +function getStatusClassName(blocked: boolean, success: boolean): string { + if (blocked) return "bg-amber-900/50 text-amber-300"; + if (success) return "bg-green-900/50 text-green-300"; + return "bg-red-900/50 text-red-300"; +} + +export function HookExecutionEventMessage({ + event, +}: HookExecutionEventMessageProps) { + const { t } = useTranslation(); + + if (!isHookExecutionEvent(event)) { + return null; + } + + const icon = getHookIcon(event.hook_event_type, event.blocked); + const statusText = getStatusText(event.blocked, event.success); + const statusClassName = getStatusClassName(event.blocked, event.success); + + // Determine the overall success indicator for GenericEventMessage. + // When blocked, suppress the success indicator entirely — the amber "blocked" + // badge in the title is the authoritative status signal. + const getSuccessStatus = (): "success" | "error" | undefined => { + if (event.blocked) return undefined; + return event.success ? "success" : "error"; + }; + const successStatus = getSuccessStatus(); + + const title = ( + + {icon} {t("HOOK$HOOK_LABEL")}: {event.hook_event_type} + {event.tool_name && ( + ({event.tool_name}) + )} + + {statusText} + + + ); + + const details = ( +
+
+ {t("HOOK$COMMAND")}:{" "} + + {formatHookCommand(event.hook_command)} + +
+ + {event.exit_code !== null && ( +
+ {t("HOOK$EXIT_CODE")}:{" "} + {event.exit_code} +
+ )} + + {event.blocked && event.reason && ( +
+ {t("HOOK$BLOCKED_REASON")}:{" "} + {event.reason} +
+ )} + + {event.additional_context && ( +
+ {t("HOOK$CONTEXT")}:{" "} + {event.additional_context} +
+ )} + + {event.error && ( +
+ {t("HOOK$ERROR")}:{" "} + {event.error} +
+ )} + + {event.stdout && ( +
+ {t("HOOK$OUTPUT")}: +
+            {event.stdout}
+          
+
+ )} + + {event.stderr && ( +
+ {t("HOOK$STDERR")}: +
+            {event.stderr}
+          
+
+ )} +
+ ); + + return ( + + ); +} diff --git a/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts b/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts index b20bedc4a9..91b206f840 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts @@ -5,6 +5,7 @@ import { isMessageEvent, isAgentErrorEvent, isConversationStateUpdateEvent, + isHookExecutionEvent, } from "#/types/v1/type-guards"; export const shouldRenderEvent = (event: OpenHandsEvent) => { @@ -50,6 +51,11 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => { return true; } + // Render hook execution events + if (isHookExecutionEvent(event)) { + return true; + } + // Don't render any other event types (system events, etc.) return false; }; diff --git a/frontend/src/components/v1/chat/event-message-components/hook-execution-event-message.tsx b/frontend/src/components/v1/chat/event-message-components/hook-execution-event-message.tsx new file mode 100644 index 0000000000..4bec021ab7 --- /dev/null +++ b/frontend/src/components/v1/chat/event-message-components/hook-execution-event-message.tsx @@ -0,0 +1 @@ +export { HookExecutionEventMessage } from "#/components/shared/hook-execution-event-message"; diff --git a/frontend/src/components/v1/chat/event-message-components/index.ts b/frontend/src/components/v1/chat/event-message-components/index.ts index 3672255101..52df9ab096 100644 --- a/frontend/src/components/v1/chat/event-message-components/index.ts +++ b/frontend/src/components/v1/chat/event-message-components/index.ts @@ -4,3 +4,4 @@ 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"; +export { HookExecutionEventMessage } from "./hook-execution-event-message"; diff --git a/frontend/src/components/v1/chat/event-message.tsx b/frontend/src/components/v1/chat/event-message.tsx index fa6299e57a..57b543bc8e 100644 --- a/frontend/src/components/v1/chat/event-message.tsx +++ b/frontend/src/components/v1/chat/event-message.tsx @@ -7,6 +7,7 @@ import { isAgentErrorEvent, isUserMessageEvent, isPlanningFileEditorObservationEvent, + isHookExecutionEvent, } from "#/types/v1/type-guards"; import { MicroagentStatus } from "#/types/microagent-status"; import { useConfig } from "#/hooks/query/use-config"; @@ -21,6 +22,7 @@ import { FinishEventMessage, GenericEventMessageWrapper, ThoughtEventMessage, + HookExecutionEventMessage, } from "./event-message-components"; import { createSkillReadyEvent } from "./event-content-helpers/create-skill-ready-event"; import { PlanPreview } from "../../features/chat/plan-preview"; @@ -188,6 +190,11 @@ export function EventMessage({ return ; } + // Hook execution events + if (isHookExecutionEvent(event)) { + return ; + } + // Finish actions if (isActionEvent(event) && event.action.kind === "FinishAction") { return ( diff --git a/frontend/src/hooks/query/use-conversation-hooks.ts b/frontend/src/hooks/query/use-conversation-hooks.ts new file mode 100644 index 0000000000..7f18fab0ba --- /dev/null +++ b/frontend/src/hooks/query/use-conversation-hooks.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; +import { useConversationId } from "../use-conversation-id"; +import { AgentState } from "#/types/agent-state"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { useSettings } from "./use-settings"; + +export const useConversationHooks = () => { + const { conversationId } = useConversationId(); + const { curAgentState } = useAgentState(); + const { data: settings } = useSettings(); + + return useQuery({ + queryKey: ["conversation", conversationId, "hooks", settings?.v1_enabled], + queryFn: async () => { + if (!conversationId) { + throw new Error("No conversation ID provided"); + } + + // Hooks are only available for V1 conversations + if (!settings?.v1_enabled) { + return []; + } + + const data = await V1ConversationService.getHooks(conversationId); + return data.hooks; + }, + enabled: + !!conversationId && + !!settings?.v1_enabled && + curAgentState !== AgentState.LOADING && + curAgentState !== AgentState.INIT, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + }); +}; diff --git a/frontend/src/hooks/use-conversation-name-context-menu.ts b/frontend/src/hooks/use-conversation-name-context-menu.ts index d20348ef8f..ed49f81b59 100644 --- a/frontend/src/hooks/use-conversation-name-context-menu.ts +++ b/frontend/src/hooks/use-conversation-name-context-menu.ts @@ -53,6 +53,7 @@ export function useConversationNameContextMenu({ const [metricsModalVisible, setMetricsModalVisible] = React.useState(false); const [systemModalVisible, setSystemModalVisible] = React.useState(false); const [skillsModalVisible, setSkillsModalVisible] = React.useState(false); + const [hooksModalVisible, setHooksModalVisible] = React.useState(false); const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = React.useState(false); const [confirmStopModalVisible, setConfirmStopModalVisible] = @@ -187,6 +188,12 @@ export function useConversationNameContextMenu({ onContextMenuToggle?.(false); }; + const handleShowHooks = (event: React.MouseEvent) => { + event.stopPropagation(); + setHooksModalVisible(true); + onContextMenuToggle?.(false); + }; + const handleTogglePublic = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -233,6 +240,7 @@ export function useConversationNameContextMenu({ handleDisplayCost, handleShowAgentTools, handleShowSkills, + handleShowHooks, handleTogglePublic, handleCopyShareLink, shareUrl, @@ -246,6 +254,8 @@ export function useConversationNameContextMenu({ setSystemModalVisible, skillsModalVisible, setSkillsModalVisible, + hooksModalVisible, + setHooksModalVisible, confirmDeleteModalVisible, setConfirmDeleteModalVisible, confirmStopModalVisible, @@ -267,5 +277,11 @@ export function useConversationNameContextMenu({ shouldShowDisplayCost: showOptions, shouldShowAgentTools: Boolean(showOptions && systemMessage), shouldShowSkills: Boolean(showOptions && conversationId), + shouldShowHooks: Boolean( + showOptions && + conversationId && + conversation?.conversation_version === "V1" && + conversationStatus === "RUNNING", + ), }; } diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 648143fc2e..8aef562320 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -683,6 +683,8 @@ export enum I18nKey { TOS$ERROR_ACCEPTING = "TOS$ERROR_ACCEPTING", TIPS$CUSTOMIZE_MICROAGENT = "TIPS$CUSTOMIZE_MICROAGENT", CONVERSATION$NO_SKILLS = "CONVERSATION$NO_SKILLS", + CONVERSATION$NO_HOOKS = "CONVERSATION$NO_HOOKS", + CONVERSATION$SHOW_HOOKS = "CONVERSATION$SHOW_HOOKS", CONVERSATION$FAILED_TO_FETCH_MICROAGENTS = "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS", MICROAGENTS_MODAL$TITLE = "MICROAGENTS_MODAL$TITLE", SKILLS_MODAL$WARNING = "SKILLS_MODAL$WARNING", @@ -1078,6 +1080,28 @@ export enum I18nKey { CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE", CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION", CONVERSATION$LINK_COPIED = "CONVERSATION$LINK_COPIED", + HOOKS_MODAL$TITLE = "HOOKS_MODAL$TITLE", + HOOKS_MODAL$WARNING = "HOOKS_MODAL$WARNING", + HOOKS_MODAL$MATCHER = "HOOKS_MODAL$MATCHER", + HOOKS_MODAL$COMMANDS = "HOOKS_MODAL$COMMANDS", + HOOKS_MODAL$HOOK_COUNT = "HOOKS_MODAL$HOOK_COUNT", + HOOKS_MODAL$TYPE = "HOOKS_MODAL$TYPE", + HOOKS_MODAL$TIMEOUT = "HOOKS_MODAL$TIMEOUT", + HOOKS_MODAL$ASYNC = "HOOKS_MODAL$ASYNC", + HOOKS_MODAL$EVENT_PRE_TOOL_USE = "HOOKS_MODAL$EVENT_PRE_TOOL_USE", + HOOKS_MODAL$EVENT_POST_TOOL_USE = "HOOKS_MODAL$EVENT_POST_TOOL_USE", + HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT = "HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT", + HOOKS_MODAL$EVENT_SESSION_START = "HOOKS_MODAL$EVENT_SESSION_START", + HOOKS_MODAL$EVENT_SESSION_END = "HOOKS_MODAL$EVENT_SESSION_END", + HOOKS_MODAL$EVENT_STOP = "HOOKS_MODAL$EVENT_STOP", + HOOK$HOOK_LABEL = "HOOK$HOOK_LABEL", + HOOK$COMMAND = "HOOK$COMMAND", + HOOK$EXIT_CODE = "HOOK$EXIT_CODE", + HOOK$BLOCKED_REASON = "HOOK$BLOCKED_REASON", + HOOK$CONTEXT = "HOOK$CONTEXT", + HOOK$ERROR = "HOOK$ERROR", + HOOK$OUTPUT = "HOOK$OUTPUT", + HOOK$STDERR = "HOOK$STDERR", COMMON$TYPE_EMAIL_AND_PRESS_SPACE = "COMMON$TYPE_EMAIL_AND_PRESS_SPACE", ORG$INVITE_ORG_MEMBERS = "ORG$INVITE_ORG_MEMBERS", ORG$MANAGE_ORGANIZATION = "ORG$MANAGE_ORGANIZATION", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 3437fde4a6..5556f891ca 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -7359,7 +7359,7 @@ "es": "Actualmente no hay un plan para este repositorio", "tr": "Şu anda bu depo için bir plan yok" }, - "SIDEBAR$NAVIGATION_LABEL": { + "SIDEBAR$NAVIGATION_LABEL": { "en": "Sidebar navigation", "zh-CN": "侧边栏导航", "zh-TW": "側邊欄導航", @@ -9327,7 +9327,6 @@ "de": "Abonnement kündigen", "uk": "Скасувати підписку" }, - "PAYMENT$SUBSCRIPTION_CANCELLED": { "en": "Subscription cancelled successfully", "ja": "サブスクリプションが正常にキャンセルされました", @@ -9344,7 +9343,6 @@ "de": "Abonnement erfolgreich gekündigt", "uk": "Підписку успішно скасовано" }, - "PAYMENT$NEXT_BILLING_DATE": { "en": "Next billing date: {{date}}", "ja": "次回請求日: {{date}}", @@ -10529,7 +10527,7 @@ "de": "klicken Sie hier für Anweisungen", "uk": "натисніть тут, щоб отримати інструкції" }, - "BITBUCKET_DATA_CENTER$TOKEN_LABEL": { + "BITBUCKET_DATA_CENTER$TOKEN_LABEL": { "en": "Bitbucket Data Center Token", "ja": "Bitbucket Data Centerトークン", "zh-CN": "Bitbucket Data Center令牌", @@ -10929,6 +10927,38 @@ "tr": "Bu sohbet için kullanılabilir yetenek bulunamadı.", "uk": "У цій розмові не знайдено доступних навичок." }, + "CONVERSATION$NO_HOOKS": { + "en": "No hooks configured for this conversation.", + "ja": "この会話にはフックが設定されていません。", + "zh-CN": "此会话未配置钩子。", + "zh-TW": "此對話未配置鉤子。", + "ko-KR": "이 대화에 구성된 훅이 없습니다.", + "no": "Ingen kroker konfigurert for denne samtalen.", + "ar": "لم يتم تكوين أي خطافات لهذه المحادثة.", + "de": "Keine Hooks für diese Unterhaltung konfiguriert.", + "fr": "Aucun hook configuré pour cette conversation.", + "it": "Nessun hook configurato per questa conversazione.", + "pt": "Nenhum hook configurado para esta conversa.", + "es": "No hay hooks configurados para esta conversación.", + "tr": "Bu sohbet için yapılandırılmış kanca yok.", + "uk": "Для цієї розмови не налаштовано хуків." + }, + "CONVERSATION$SHOW_HOOKS": { + "en": "Show Available Hooks", + "ja": "利用可能なフックを表示", + "zh-CN": "显示可用钩子", + "zh-TW": "顯示可用鉤子", + "ko-KR": "사용 가능한 훅 표시", + "no": "Vis tilgjengelige kroker", + "ar": "عرض الخطافات المتاحة", + "de": "Verfügbare Hooks anzeigen", + "fr": "Afficher les hooks disponibles", + "it": "Mostra hook disponibili", + "pt": "Mostrar hooks disponíveis", + "es": "Mostrar hooks disponibles", + "tr": "Kullanılabilir kancaları göster", + "uk": "Показати доступні хуки" + }, "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS": { "en": "Failed to fetch available microagents", "ja": "利用可能なマイクロエージェントの取得に失敗しました", @@ -11777,7 +11807,6 @@ "tr": "Git sağlayıcısını bağla", "uk": "Підключити постачальник Git" }, - "TASKS$NO_GIT_PROVIDERS_DESCRIPTION": { "en": "Connect a Git provider to see suggested tasks from your repositories.", "ja": "Gitプロバイダーを接続して、リポジトリからの提案タスクを表示します。", @@ -11794,7 +11823,6 @@ "tr": "Depolarınızdan önerilen görevleri görmek için bir Git sağlayıcısı bağlayın.", "uk": "Підключіть постачальник Git, щоб бачити запропоновані завдання з ваших репозиторіїв." }, - "TASKS$NO_GIT_PROVIDERS_CTA": { "en": "Go to Integrations", "ja": "統合へ移動", @@ -17251,6 +17279,358 @@ "de": "Link in die Zwischenablage kopiert", "uk": "Посилання скопійовано в буфер обміну" }, + "HOOKS_MODAL$TITLE": { + "en": "Available Hooks", + "ja": "利用可能なフック", + "zh-CN": "可用钩子", + "zh-TW": "可用鉤子", + "ko-KR": "사용 가능한 훅", + "no": "Tilgjengelige kroker", + "ar": "الخطافات المتاحة", + "de": "Verfügbare Hooks", + "fr": "Hooks disponibles", + "it": "Hook disponibili", + "pt": "Hooks disponíveis", + "es": "Hooks disponibles", + "tr": "Kullanılabilir kancalar", + "uk": "Доступні хуки" + }, + "HOOKS_MODAL$WARNING": { + "en": "Hooks are loaded from your workspace. This view refreshes on demand and may differ from the hooks that were active when the conversation started. Stop and restart the conversation to apply changes.", + "ja": "フックはワークスペースから読み込まれます。この表示は要求時にワークスペースから再読み込みするため、会話開始時に有効だったフックと異なる場合があります。変更を適用するには会話を停止して再開してください。", + "zh-CN": "Hooks 从工作区读取。本视图会在请求时从工作区刷新,因此可能与会话启动时生效的 hooks 不一致。要应用更改,请停止并重新开始会话。", + "zh-TW": "Hooks 從工作區讀取。本視圖會在請求時從工作區重新整理,因此可能與會話啟動時生效的 hooks 不一致。要套用變更,請停止並重新開始會話。", + "ko-KR": "훅은 작업공간에서 로드됩니다. 이 화면은 요청 시 작업공간에서 다시 읽어 오므로 대화 시작 시 적용된 훅과 다를 수 있습니다. 변경을 적용하려면 대화를 중지한 뒤 다시 시작하세요.", + "no": "Hooks lastes fra arbeidsområdet. Denne visningen leser filen på nytt ved forespørsel og kan derfor avvike fra hookene som var aktive da samtalen startet. Stopp og start samtalen på nytt for å ta i bruk endringer.", + "ar": "يتم تحميل الخطافات من مساحة العمل. تقوم هذه الشاشة بإعادة قراءة الملف عند الطلب وقد تختلف عن الخطافات التي كانت فعّالة عند بدء المحادثة. لتطبيق التغييرات، أوقف المحادثة وأعد تشغيلها.", + "de": "Hooks werden aus dem Workspace geladen. Diese Ansicht liest die Datei bei Bedarf neu ein und kann daher von den Hooks abweichen, die beim Start der Unterhaltung aktiv waren. Stoppen und starten Sie die Unterhaltung neu, um Änderungen anzuwenden.", + "fr": "Les hooks sont chargés depuis votre espace de travail. Cette vue se rafraîchit à la demande depuis l’espace de travail et peut différer des hooks actifs au démarrage de la conversation. Arrêtez puis redémarrez la conversation pour appliquer les modifications.", + "it": "Gli hook vengono caricati dal tuo workspace. Questa vista si aggiorna su richiesta dal workspace e può differire dagli hook attivi all’avvio della conversazione. Interrompi e riavvia la conversazione per applicare le modifiche.", + "pt": "Os hooks são carregados do seu workspace. Esta visualização é atualizada sob demanda a partir do workspace e pode ser diferente dos hooks que estavam ativos quando a conversa foi iniciada. Pare e reinicie a conversa para aplicar as alterações.", + "es": "Los hooks se cargan desde tu espacio de trabajo. Esta vista se actualiza bajo demanda desde el workspace y puede diferir de los hooks que estaban activos cuando comenzó la conversación. Detén y reinicia la conversación para aplicar los cambios.", + "tr": "Kancalar çalışma alanınızdan yüklenir. Bu görünüm istek üzerine çalışma alanından yenilenir ve sohbet başlatıldığında etkin olan kancalardan farklı olabilir. Değişiklikleri uygulamak için sohbeti durdurup yeniden başlatın.", + "uk": "Хуки завантажуються з вашого робочого простору. Це подання оновлюється з робочого простору на вимогу й може відрізнятися від хуків, які були активні під час запуску розмови. Щоб застосувати зміни, зупиніть і перезапустіть розмову." + }, + "HOOKS_MODAL$MATCHER": { + "en": "Matcher", + "ja": "マッチャー", + "zh-CN": "匹配器", + "zh-TW": "匹配器", + "ko-KR": "매처", + "no": "Matcher", + "ar": "المطابق", + "de": "Matcher", + "fr": "Matcher", + "it": "Matcher", + "pt": "Matcher", + "es": "Matcher", + "tr": "Eşleştirici", + "uk": "Матчер" + }, + "HOOKS_MODAL$COMMANDS": { + "en": "Commands", + "ja": "コマンド", + "zh-CN": "命令", + "zh-TW": "命令", + "ko-KR": "명령", + "no": "Kommandoer", + "ar": "الأوامر", + "de": "Befehle", + "fr": "Commandes", + "it": "Comandi", + "pt": "Comandos", + "es": "Comandos", + "tr": "Komutlar", + "uk": "Команди" + }, + "HOOKS_MODAL$HOOK_COUNT": { + "en": "{{count}} hook(s)", + "ja": "{{count}}個のフック", + "zh-CN": "{{count}}个钩子", + "zh-TW": "{{count}}個鉤子", + "ko-KR": "{{count}}개 훅", + "no": "{{count}} krok", + "ar": "{{count}} خطاف", + "de": "{{count}} Hook", + "fr": "{{count}} hook", + "it": "{{count}} hook", + "pt": "{{count}} hook", + "es": "{{count}} hook", + "tr": "{{count}} kanca", + "uk": "{{count}} хук" + }, + "HOOKS_MODAL$TYPE": { + "en": "Type: {{type}}", + "ja": "タイプ: {{type}}", + "zh-CN": "类型: {{type}}", + "zh-TW": "類型: {{type}}", + "ko-KR": "유형: {{type}}", + "no": "Type: {{type}}", + "ar": "النوع: {{type}}", + "de": "Typ: {{type}}", + "fr": "Type: {{type}}", + "it": "Tipo: {{type}}", + "pt": "Tipo: {{type}}", + "es": "Tipo: {{type}}", + "tr": "Tür: {{type}}", + "uk": "Тип: {{type}}" + }, + "HOOKS_MODAL$TIMEOUT": { + "en": "Timeout: {{timeout}}s", + "ja": "タイムアウト: {{timeout}}秒", + "zh-CN": "超时: {{timeout}}秒", + "zh-TW": "超時: {{timeout}}秒", + "ko-KR": "타임아웃: {{timeout}}초", + "no": "Tidsavbrudd: {{timeout}}s", + "ar": "المهلة: {{timeout}} ثانية", + "de": "Timeout: {{timeout}}s", + "fr": "Délai: {{timeout}}s", + "it": "Timeout: {{timeout}}s", + "pt": "Tempo limite: {{timeout}}s", + "es": "Tiempo de espera: {{timeout}}s", + "tr": "Zaman aşımı: {{timeout}}s", + "uk": "Таймаут: {{timeout}}с" + }, + "HOOKS_MODAL$ASYNC": { + "en": "Async", + "ja": "非同期", + "zh-CN": "异步", + "zh-TW": "非同步", + "ko-KR": "비동기", + "no": "Asynkron", + "ar": "غير متزامن", + "de": "Asynchron", + "fr": "Asynchrone", + "it": "Asincrono", + "pt": "Assíncrono", + "es": "Asíncrono", + "tr": "Asenkron", + "uk": "Асинхронний" + }, + "HOOKS_MODAL$EVENT_PRE_TOOL_USE": { + "en": "Pre Tool Use", + "ja": "ツール使用前", + "zh-CN": "工具使用前", + "zh-TW": "工具使用前", + "ko-KR": "도구 사용 전", + "no": "Før verktøybruk", + "ar": "قبل استخدام الأداة", + "de": "Vor Werkzeugnutzung", + "fr": "Avant utilisation de l'outil", + "it": "Prima dell'uso dello strumento", + "pt": "Antes do uso da ferramenta", + "es": "Antes del uso de la herramienta", + "tr": "Araç kullanımı öncesi", + "uk": "Перед використанням інструменту" + }, + "HOOKS_MODAL$EVENT_POST_TOOL_USE": { + "en": "Post Tool Use", + "ja": "ツール使用後", + "zh-CN": "工具使用后", + "zh-TW": "工具使用後", + "ko-KR": "도구 사용 후", + "no": "Etter verktøybruk", + "ar": "بعد استخدام الأداة", + "de": "Nach Werkzeugnutzung", + "fr": "Après utilisation de l'outil", + "it": "Dopo l'uso dello strumento", + "pt": "Após o uso da ferramenta", + "es": "Después del uso de la herramienta", + "tr": "Araç kullanımı sonrası", + "uk": "Після використання інструменту" + }, + "HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT": { + "en": "User Prompt Submit", + "ja": "ユーザープロンプト送信", + "zh-CN": "用户提示提交", + "zh-TW": "使用者提示提交", + "ko-KR": "사용자 프롬프트 제출", + "no": "Brukerforespørsel sendt", + "ar": "إرسال طلب المستخدم", + "de": "Benutzeranfrage gesendet", + "fr": "Soumission de l'invite utilisateur", + "it": "Invio prompt utente", + "pt": "Envio de prompt do usuário", + "es": "Envío de solicitud del usuario", + "tr": "Kullanıcı istemi gönderimi", + "uk": "Надсилання запиту користувача" + }, + "HOOKS_MODAL$EVENT_SESSION_START": { + "en": "Session Start", + "ja": "セッション開始", + "zh-CN": "会话开始", + "zh-TW": "會話開始", + "ko-KR": "세션 시작", + "no": "Øktstart", + "ar": "بدء الجلسة", + "de": "Sitzungsstart", + "fr": "Début de session", + "it": "Inizio sessione", + "pt": "Início da sessão", + "es": "Inicio de sesión", + "tr": "Oturum başlangıcı", + "uk": "Початок сесії" + }, + "HOOKS_MODAL$EVENT_SESSION_END": { + "en": "Session End", + "ja": "セッション終了", + "zh-CN": "会话结束", + "zh-TW": "會話結束", + "ko-KR": "세션 종료", + "no": "Øktslutt", + "ar": "نهاية الجلسة", + "de": "Sitzungsende", + "fr": "Fin de session", + "it": "Fine sessione", + "pt": "Fim da sessão", + "es": "Fin de sesión", + "tr": "Oturum sonu", + "uk": "Кінець сесії" + }, + "HOOKS_MODAL$EVENT_STOP": { + "en": "Stop", + "ja": "停止", + "zh-CN": "停止", + "zh-TW": "停止", + "ko-KR": "중지", + "no": "Stopp", + "ar": "إيقاف", + "de": "Stopp", + "fr": "Arrêt", + "it": "Stop", + "pt": "Parar", + "es": "Detener", + "tr": "Durdur", + "uk": "Зупинка" + }, + "HOOK$HOOK_LABEL": { + "en": "Hook", + "ja": "フック", + "zh-CN": "钩子", + "zh-TW": "鈎子", + "ko-KR": "훅", + "no": "Krok", + "ar": "خطاف", + "de": "Hook", + "fr": "Crochet", + "it": "Hook", + "pt": "Hook", + "es": "Gancho", + "tr": "Kanca", + "uk": "Хук" + }, + "HOOK$COMMAND": { + "en": "Command", + "ja": "コマンド", + "zh-CN": "命令", + "zh-TW": "命令", + "ko-KR": "명령", + "no": "Kommando", + "ar": "أمر", + "de": "Befehl", + "fr": "Commande", + "it": "Comando", + "pt": "Comando", + "es": "Comando", + "tr": "Komut", + "uk": "Команда" + }, + "HOOK$EXIT_CODE": { + "en": "Exit code", + "ja": "終了コード", + "zh-CN": "退出码", + "zh-TW": "退出碼", + "ko-KR": "종료 코드", + "no": "Avslutningskode", + "ar": "رمز الخروج", + "de": "Exit-Code", + "fr": "Code de sortie", + "it": "Codice di uscita", + "pt": "Código de saída", + "es": "Código de salida", + "tr": "Çıkış kodu", + "uk": "Код виходу" + }, + "HOOK$BLOCKED_REASON": { + "en": "Blocked reason", + "ja": "ブロック理由", + "zh-CN": "阻止原因", + "zh-TW": "阻止原因", + "ko-KR": "차단 이유", + "no": "Blokkert grunn", + "ar": "سبب الحظر", + "de": "Blockierungsgrund", + "fr": "Raison du blocage", + "it": "Motivo del blocco", + "pt": "Motivo do bloqueio", + "es": "Motivo del bloqueo", + "tr": "Engelleme nedeni", + "uk": "Причина блокування" + }, + "HOOK$CONTEXT": { + "en": "Context", + "ja": "コンテキスト", + "zh-CN": "上下文", + "zh-TW": "上下文", + "ko-KR": "컨텍스트", + "no": "Kontekst", + "ar": "سياق", + "de": "Kontext", + "fr": "Contexte", + "it": "Contesto", + "pt": "Contexto", + "es": "Contexto", + "tr": "Bağlam", + "uk": "Контекст" + }, + "HOOK$ERROR": { + "en": "Error", + "ja": "エラー", + "zh-CN": "错误", + "zh-TW": "錯誤", + "ko-KR": "오류", + "no": "Feil", + "ar": "خطأ", + "de": "Fehler", + "fr": "Erreur", + "it": "Errore", + "pt": "Erro", + "es": "Error", + "tr": "Hata", + "uk": "Помилка" + }, + "HOOK$OUTPUT": { + "en": "Output", + "ja": "出力", + "zh-CN": "输出", + "zh-TW": "輸出", + "ko-KR": "출력", + "no": "Utdata", + "ar": "الإخراج", + "de": "Ausgabe", + "fr": "Sortie", + "it": "Output", + "pt": "Saída", + "es": "Salida", + "tr": "Çıktı", + "uk": "Вивід" + }, + "HOOK$STDERR": { + "en": "Stderr", + "ja": "標準エラー", + "zh-CN": "标准错误", + "zh-TW": "標準錯誤", + "ko-KR": "표준 오류", + "no": "Standardfeil", + "ar": "خطأ قياسي", + "de": "Standardfehler", + "fr": "Erreur standard", + "it": "Errore standard", + "pt": "Erro padrão", + "es": "Error estándar", + "tr": "Standart hata", + "uk": "Стандартна помилка" + }, "COMMON$TYPE_EMAIL_AND_PRESS_SPACE": { "en": "Type email and press Space", "ja": "メールアドレスを入力してスペースキーを押してください", diff --git a/frontend/src/types/core/base.ts b/frontend/src/types/core/base.ts index e305bf7d4d..97f56b245d 100644 --- a/frontend/src/types/core/base.ts +++ b/frontend/src/types/core/base.ts @@ -21,7 +21,7 @@ export type OpenHandsEventType = | "task_tracking" | "user_rejected"; -export type OpenHandsSourceType = "agent" | "user" | "environment"; +export type OpenHandsSourceType = "agent" | "user" | "environment" | "hook"; interface OpenHandsBaseEvent { id: number; diff --git a/frontend/src/types/v1/core/base/common.ts b/frontend/src/types/v1/core/base/common.ts index 3e03cc1484..56777d527d 100644 --- a/frontend/src/types/v1/core/base/common.ts +++ b/frontend/src/types/v1/core/base/common.ts @@ -53,7 +53,7 @@ export type EventID = string; export type ToolCallID = string; // Source type for events -export type SourceType = "agent" | "user" | "environment"; +export type SourceType = "agent" | "user" | "environment" | "hook"; // Security risk levels export enum SecurityRisk { diff --git a/frontend/src/types/v1/core/events/hook-execution-event.ts b/frontend/src/types/v1/core/events/hook-execution-event.ts new file mode 100644 index 0000000000..7495a754ee --- /dev/null +++ b/frontend/src/types/v1/core/events/hook-execution-event.ts @@ -0,0 +1,100 @@ +import { BaseEvent } from "../base/event"; + +/** + * Hook event types supported by the system + */ +export type HookEventType = + | "PreToolUse" + | "PostToolUse" + | "UserPromptSubmit" + | "SessionStart" + | "SessionEnd" + | "Stop"; + +/** + * HookExecutionEvent - emitted when a hook script executes + * + * Provides observability into hook execution for PreToolUse, PostToolUse, + * UserPromptSubmit, SessionStart, SessionEnd, and Stop hooks. + */ +export interface HookExecutionEvent extends BaseEvent { + /** + * Discriminator field for type guards + */ + kind: "HookExecutionEvent"; + + /** + * The source is always "hook" for hook execution events + */ + source: "hook"; + + /** + * Type of hook that was executed + */ + hook_event_type: HookEventType; + + /** + * The command that was executed + */ + hook_command: string; + + /** + * Whether the hook executed successfully + */ + success: boolean; + + /** + * Whether the hook blocked the action + */ + blocked: boolean; + + /** + * Exit code from the hook script (null if not applicable) + */ + exit_code: number | null; + + /** + * Reason provided by the hook for blocking (if blocked) + */ + reason: string | null; + + /** + * Name of the tool (for PreToolUse/PostToolUse hooks) + */ + tool_name: string | null; + + /** + * ID of the related action event (for tool hooks) + */ + action_id: string | null; + + /** + * ID of the related message event (for UserPromptSubmit hooks) + */ + message_id: string | null; + + /** + * Standard output from the hook script + */ + stdout: string | null; + + /** + * Standard error from the hook script + */ + stderr: string | null; + + /** + * Error message if the hook failed + */ + error: string | null; + + /** + * Additional context provided by the hook + */ + additional_context: string | null; + + /** + * Input data that was passed to the hook + */ + hook_input: Record | null; +} diff --git a/frontend/src/types/v1/core/events/index.ts b/frontend/src/types/v1/core/events/index.ts index e3d3ee6cb4..388002a52f 100644 --- a/frontend/src/types/v1/core/events/index.ts +++ b/frontend/src/types/v1/core/events/index.ts @@ -2,6 +2,7 @@ export * from "./action-event"; export * from "./condensation-event"; export * from "./conversation-state-event"; +export * from "./hook-execution-event"; export * from "./message-event"; export * from "./observation-event"; export * from "./pause-event"; diff --git a/frontend/src/types/v1/core/openhands-event.ts b/frontend/src/types/v1/core/openhands-event.ts index 4793c5a0ae..fc3a46f714 100644 --- a/frontend/src/types/v1/core/openhands-event.ts +++ b/frontend/src/types/v1/core/openhands-event.ts @@ -11,6 +11,7 @@ import { CondensationSummaryEvent, ConversationStateUpdateEvent, ConversationErrorEvent, + HookExecutionEvent, PauseEvent, } from "./events/index"; @@ -26,6 +27,8 @@ export type OpenHandsEvent = | UserRejectObservation | AgentErrorEvent | SystemPromptEvent + // Hook events + | HookExecutionEvent // Conversation management events | CondensationEvent | CondensationRequestEvent diff --git a/frontend/src/types/v1/type-guards.ts b/frontend/src/types/v1/type-guards.ts index dec1816209..b4fa1c9f5f 100644 --- a/frontend/src/types/v1/type-guards.ts +++ b/frontend/src/types/v1/type-guards.ts @@ -20,6 +20,7 @@ import { ConversationStateUpdateEventStats, ConversationErrorEvent, } from "./core/events/conversation-state-event"; +import { HookExecutionEvent } from "./core/events/hook-execution-event"; import { SystemPromptEvent } from "./core/events/system-event"; import type { OpenHandsParsedEvent } from "../core/index"; @@ -42,7 +43,8 @@ export function isBaseEvent(value: unknown): value is BaseEvent { typeof value.source === "string" && (value.source === "agent" || value.source === "user" || - value.source === "environment") + value.source === "environment" || + value.source === "hook") ); } @@ -191,6 +193,14 @@ export const isConversationErrorEvent = ( ): event is ConversationErrorEvent => "kind" in event && event.kind === "ConversationErrorEvent"; +/** + * Type guard function to check if an event is a hook execution event + */ +export const isHookExecutionEvent = ( + event: OpenHandsEvent, +): event is HookExecutionEvent => + "kind" in event && event.kind === "HookExecutionEvent"; + // ============================================================================= // TEMPORARY COMPATIBILITY TYPE GUARDS // These will be removed once we fully migrate to V1 events diff --git a/openhands/app_server/app_conversation/app_conversation_models.py b/openhands/app_server/app_conversation/app_conversation_models.py index b7a4cc4dce..f4a4467e8c 100644 --- a/openhands/app_server/app_conversation/app_conversation_models.py +++ b/openhands/app_server/app_conversation/app_conversation_models.py @@ -242,3 +242,32 @@ class SkillResponse(BaseModel): type: Literal['repo', 'knowledge', 'agentskills'] content: str triggers: list[str] = [] + + +class HookDefinitionResponse(BaseModel): + """Response model for a single hook definition.""" + + type: str # 'command' or 'prompt' + command: str + timeout: int = 60 + async_: bool = Field(default=False, serialization_alias='async') + + +class HookMatcherResponse(BaseModel): + """Response model for a hook matcher.""" + + matcher: str # Pattern: '*', exact match, or regex + hooks: list[HookDefinitionResponse] = [] + + +class HookEventResponse(BaseModel): + """Response model for hooks of a specific event type.""" + + event_type: str # e.g., 'stop', 'pre_tool_use', 'post_tool_use' + matchers: list[HookMatcherResponse] = [] + + +class GetHooksResponse(BaseModel): + """Response model for hooks endpoint.""" + + hooks: list[HookEventResponse] = [] diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py index 50a8497a85..6babd41dc0 100644 --- a/openhands/app_server/app_conversation/app_conversation_router.py +++ b/openhands/app_server/app_conversation/app_conversation_router.py @@ -5,43 +5,29 @@ import logging import os import sys import tempfile +from dataclasses import dataclass from datetime import datetime from typing import Annotated, AsyncGenerator, Literal from uuid import UUID import httpx - -from openhands.app_server.services.db_session_injector import set_db_session_keep_open -from openhands.app_server.services.httpx_client_injector import ( - set_httpx_client_keep_open, -) -from openhands.app_server.services.injector import InjectorState -from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR -from openhands.app_server.user.user_context import UserContext -from openhands.server.dependencies import get_dependencies - -# Handle anext compatibility for Python < 3.10 -if sys.version_info >= (3, 10): - from builtins import anext -else: - - async def anext(async_iterator): - """Compatibility function for anext in Python < 3.10""" - return await async_iterator.__anext__() - - from fastapi import APIRouter, HTTPException, Query, Request, Response, status from fastapi.responses import JSONResponse, StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from openhands.app_server.app_conversation.app_conversation_models import ( AppConversation, + AppConversationInfo, AppConversationPage, AppConversationStartRequest, AppConversationStartTask, AppConversationStartTaskPage, AppConversationStartTaskSortOrder, AppConversationUpdateRequest, + GetHooksResponse, + HookDefinitionResponse, + HookEventResponse, + HookMatcherResponse, SkillResponse, ) from openhands.app_server.app_conversation.app_conversation_service import ( @@ -66,15 +52,35 @@ from openhands.app_server.config import ( ) from openhands.app_server.sandbox.sandbox_models import ( AGENT_SERVER, + SandboxInfo, SandboxStatus, ) from openhands.app_server.sandbox.sandbox_service import SandboxService +from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService +from openhands.app_server.services.db_session_injector import set_db_session_keep_open +from openhands.app_server.services.httpx_client_injector import ( + set_httpx_client_keep_open, +) +from openhands.app_server.services.injector import InjectorState +from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR +from openhands.app_server.user.user_context import UserContext from openhands.app_server.utils.docker_utils import ( replace_localhost_hostname_for_docker, ) from openhands.sdk.context.skills import KeywordTrigger, TaskTrigger from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace +from openhands.server.dependencies import get_dependencies + +# Handle anext compatibility for Python < 3.10 +if sys.version_info >= (3, 10): + from builtins import anext +else: + + async def anext(async_iterator): + """Compatibility function for anext in Python < 3.10""" + return await async_iterator.__anext__() + # We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint # is protected. The actual protection is provided by SetAuthCookieMiddleware @@ -92,6 +98,96 @@ httpx_client_dependency = depends_httpx_client() sandbox_service_dependency = depends_sandbox_service() sandbox_spec_service_dependency = depends_sandbox_spec_service() + +@dataclass +class AgentServerContext: + """Context for accessing the agent server for a conversation.""" + + conversation: AppConversationInfo + sandbox: SandboxInfo + sandbox_spec: SandboxSpecInfo + agent_server_url: str + session_api_key: str | None + + +async def _get_agent_server_context( + conversation_id: UUID, + app_conversation_service: AppConversationService, + sandbox_service: SandboxService, + sandbox_spec_service: SandboxSpecService, +) -> AgentServerContext | JSONResponse: + """Get the agent server context for a conversation. + + This helper retrieves all necessary information to communicate with the + agent server for a given conversation, including the sandbox info, + sandbox spec, and agent server URL. + + Args: + conversation_id: The conversation ID + app_conversation_service: Service for conversation operations + sandbox_service: Service for sandbox operations + sandbox_spec_service: Service for sandbox spec operations + + Returns: + AgentServerContext if successful, or JSONResponse with error details. + """ + # Get the conversation info + conversation = await app_conversation_service.get_app_conversation(conversation_id) + if not conversation: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={'error': f'Conversation {conversation_id} not found'}, + ) + + # Get the sandbox info + sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id) + if not sandbox or sandbox.status != SandboxStatus.RUNNING: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={ + 'error': f'Sandbox not found or not running for conversation {conversation_id}' + }, + ) + + # Get the sandbox spec to find the working directory + sandbox_spec = await sandbox_spec_service.get_sandbox_spec(sandbox.sandbox_spec_id) + if not sandbox_spec: + # TODO: This is a temporary work around for the fact that we don't store previous + # sandbox spec versions when updating OpenHands. When the SandboxSpecServices + # transition to truly multi sandbox spec model this should raise a 404 error + logger.warning('Sandbox spec not found - using default.') + sandbox_spec = await sandbox_spec_service.get_default_sandbox_spec() + + # Get the agent server URL + if not sandbox.exposed_urls: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={'error': 'No agent server URL found for sandbox'}, + ) + + agent_server_url = None + for exposed_url in sandbox.exposed_urls: + if exposed_url.name == AGENT_SERVER: + agent_server_url = exposed_url.url + break + + if not agent_server_url: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={'error': 'Agent server URL not found in sandbox'}, + ) + + agent_server_url = replace_localhost_hostname_for_docker(agent_server_url) + + return AgentServerContext( + conversation=conversation, + sandbox=sandbox, + sandbox_spec=sandbox_spec, + agent_server_url=agent_server_url, + session_api_key=sandbox.session_api_key, + ) + + # Read methods @@ -493,57 +589,15 @@ async def get_conversation_skills( JSONResponse: A JSON response containing the list of skills. """ try: - # Get the conversation info - conversation = await app_conversation_service.get_app_conversation( - conversation_id + # Get agent server context (conversation, sandbox, sandbox_spec, agent_server_url) + ctx = await _get_agent_server_context( + conversation_id, + app_conversation_service, + sandbox_service, + sandbox_spec_service, ) - if not conversation: - return JSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={'error': f'Conversation {conversation_id} not found'}, - ) - - # Get the sandbox info - sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id) - if not sandbox or sandbox.status != SandboxStatus.RUNNING: - return JSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={ - 'error': f'Sandbox not found or not running for conversation {conversation_id}' - }, - ) - - # Get the sandbox spec to find the working directory - sandbox_spec = await sandbox_spec_service.get_sandbox_spec( - sandbox.sandbox_spec_id - ) - if not sandbox_spec: - # TODO: This is a temporary work around for the fact that we don't store previous - # sandbox spec versions when updating OpenHands. When the SandboxSpecServices - # transition to truly multi sandbox spec model this should raise a 404 error - logger.warning('Sandbox spec not found - using default.') - sandbox_spec = await sandbox_spec_service.get_default_sandbox_spec() - - # Get the agent server URL - if not sandbox.exposed_urls: - return JSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={'error': 'No agent server URL found for sandbox'}, - ) - - agent_server_url = None - for exposed_url in sandbox.exposed_urls: - if exposed_url.name == AGENT_SERVER: - agent_server_url = exposed_url.url - break - - if not agent_server_url: - return JSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={'error': 'Agent server URL not found in sandbox'}, - ) - - agent_server_url = replace_localhost_hostname_for_docker(agent_server_url) + if isinstance(ctx, JSONResponse): + return ctx # Load skills from all sources logger.info(f'Loading skills for conversation {conversation_id}') @@ -552,13 +606,13 @@ async def get_conversation_skills( all_skills: list = [] if isinstance(app_conversation_service, AppConversationServiceBase): project_dir = get_project_dir( - sandbox_spec.working_dir, conversation.selected_repository + ctx.sandbox_spec.working_dir, ctx.conversation.selected_repository ) all_skills = await app_conversation_service.load_and_merge_all_skills( - sandbox, - conversation.selected_repository, + ctx.sandbox, + ctx.conversation.selected_repository, project_dir, - agent_server_url, + ctx.agent_server_url, ) logger.info( @@ -608,6 +662,147 @@ async def get_conversation_skills( ) +@router.get('/{conversation_id}/hooks') +async def get_conversation_hooks( + conversation_id: UUID, + app_conversation_service: AppConversationService = ( + app_conversation_service_dependency + ), + sandbox_service: SandboxService = sandbox_service_dependency, + sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency, + httpx_client: httpx.AsyncClient = httpx_client_dependency, +) -> JSONResponse: + """Get hooks currently configured in the workspace for this conversation. + + This endpoint loads hooks from the conversation's project directory in the + workspace (i.e. `{project_dir}/.openhands/hooks.json`) at request time. + + Note: + This is intentionally a "live" view of the workspace configuration. + If `.openhands/hooks.json` changes over time, this endpoint reflects the + latest file content and may not match the hooks that were used when the + conversation originally started. + + Returns: + JSONResponse: A JSON response containing the list of hook event types. + """ + try: + # Get agent server context (conversation, sandbox, sandbox_spec, agent_server_url) + ctx = await _get_agent_server_context( + conversation_id, + app_conversation_service, + sandbox_service, + sandbox_spec_service, + ) + if isinstance(ctx, JSONResponse): + return ctx + + from openhands.app_server.app_conversation.hook_loader import ( + fetch_hooks_from_agent_server, + get_project_dir_for_hooks, + ) + + project_dir = get_project_dir_for_hooks( + ctx.sandbox_spec.working_dir, + ctx.conversation.selected_repository, + ) + + # Load hooks from agent-server (using the error-raising variant so + # HTTP/connection failures are surfaced to the user, not hidden). + logger.debug( + f'Loading hooks for conversation {conversation_id}, ' + f'agent_server_url={ctx.agent_server_url}, ' + f'project_dir={project_dir}' + ) + + try: + hook_config = await fetch_hooks_from_agent_server( + agent_server_url=ctx.agent_server_url, + session_api_key=ctx.session_api_key, + project_dir=project_dir, + httpx_client=httpx_client, + ) + except httpx.HTTPStatusError as e: + logger.warning( + f'Agent-server returned {e.response.status_code} when loading hooks ' + f'for conversation {conversation_id}: {e.response.text}' + ) + return JSONResponse( + status_code=status.HTTP_502_BAD_GATEWAY, + content={ + 'error': f'Agent-server returned status {e.response.status_code} when loading hooks' + }, + ) + except httpx.RequestError as e: + logger.warning( + f'Failed to reach agent-server when loading hooks ' + f'for conversation {conversation_id}: {e}' + ) + return JSONResponse( + status_code=status.HTTP_502_BAD_GATEWAY, + content={'error': 'Failed to reach agent-server when loading hooks'}, + ) + + # Transform hook_config to response format + hooks_response: list[HookEventResponse] = [] + + if hook_config: + # Define the event types to check + event_types = [ + 'pre_tool_use', + 'post_tool_use', + 'user_prompt_submit', + 'session_start', + 'session_end', + 'stop', + ] + + for field_name in event_types: + matchers = getattr(hook_config, field_name, []) + if matchers: + matcher_responses = [] + for matcher in matchers: + hook_defs = [ + HookDefinitionResponse( + type=hook.type.value + if hasattr(hook.type, 'value') + else str(hook.type), + command=hook.command, + timeout=hook.timeout, + async_=hook.async_, + ) + for hook in matcher.hooks + ] + matcher_responses.append( + HookMatcherResponse( + matcher=matcher.matcher, + hooks=hook_defs, + ) + ) + hooks_response.append( + HookEventResponse( + event_type=field_name, + matchers=matcher_responses, + ) + ) + + logger.debug( + f'Loaded {len(hooks_response)} hook event types for conversation {conversation_id}' + ) + + return JSONResponse( + status_code=status.HTTP_200_OK, + content=GetHooksResponse(hooks=hooks_response).model_dump(by_alias=True), + ) + + except Exception as e: + logger.error(f'Error getting hooks for conversation {conversation_id}: {e}') + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={'error': f'Error getting hooks: {str(e)}'}, + ) + + @router.get('/{conversation_id}/download') async def export_conversation( conversation_id: UUID, diff --git a/openhands/app_server/app_conversation/hook_loader.py b/openhands/app_server/app_conversation/hook_loader.py new file mode 100644 index 0000000000..619ce68d43 --- /dev/null +++ b/openhands/app_server/app_conversation/hook_loader.py @@ -0,0 +1,148 @@ +"""Utilities for loading hooks for V1 conversations. + +This module provides functions to load hooks from the agent-server, +which centralizes all hook loading logic. The app-server acts as a +thin proxy that calls the agent-server's /api/hooks endpoint. + +All hook loading is handled by the agent-server. +""" + +import logging + +import httpx + +from openhands.sdk.hooks import HookConfig + +_logger = logging.getLogger(__name__) + + +def get_project_dir_for_hooks( + working_dir: str, + selected_repository: str | None = None, +) -> str: + """Get the project directory path for loading hooks. + + When a repository is selected, hooks are loaded from + {working_dir}/{repo_name}/.openhands/hooks.json. + Otherwise, hooks are loaded from {working_dir}/.openhands/hooks.json. + + Args: + working_dir: Base working directory path in the sandbox + selected_repository: Repository name (e.g., 'OpenHands/software-agent-sdk') + If provided, the repo name is appended to working_dir. + + Returns: + The project directory path where hooks.json should be located. + """ + if selected_repository: + repo_name = selected_repository.split('/')[-1] + return f'{working_dir}/{repo_name}' + return working_dir + + +async def fetch_hooks_from_agent_server( + agent_server_url: str, + session_api_key: str | None, + project_dir: str, + httpx_client: httpx.AsyncClient, +) -> HookConfig | None: + """Fetch hooks from the agent-server, raising on HTTP/connection errors. + + This is the low-level function that makes a single API call to the + agent-server's /api/hooks endpoint. It raises on HTTP and connection + errors so callers can decide how to handle failures. + + Args: + agent_server_url: URL of the agent server (e.g., 'http://localhost:8000') + session_api_key: Session API key for authentication (optional) + project_dir: Workspace directory path for project hooks + httpx_client: Shared HTTP client for making the request + + Returns: + HookConfig if hooks.json exists and is valid, None if no hooks found. + + Raises: + httpx.HTTPStatusError: If the agent-server returns a non-2xx status. + httpx.RequestError: If the agent-server is unreachable. + """ + _logger.debug( + f'fetch_hooks_from_agent_server called: ' + f'agent_server_url={agent_server_url}, project_dir={project_dir}' + ) + payload = {'project_dir': project_dir} + + headers = {'Content-Type': 'application/json'} + if session_api_key: + headers['X-Session-API-Key'] = session_api_key + + response = await httpx_client.post( + f'{agent_server_url}/api/hooks', + json=payload, + headers=headers, + timeout=30.0, + ) + response.raise_for_status() + + data = response.json() + + hook_config_data = data.get('hook_config') + if hook_config_data is None: + _logger.debug('No hooks found in workspace') + return None + + hook_config = HookConfig.from_dict(hook_config_data) + + if hook_config.is_empty(): + _logger.debug('Hooks config is empty') + return None + + _logger.debug(f'Loaded hooks from agent-server for {project_dir}') + return hook_config + + +async def load_hooks_from_agent_server( + agent_server_url: str, + session_api_key: str | None, + project_dir: str, + httpx_client: httpx.AsyncClient, +) -> HookConfig | None: + """Load hooks from the agent-server, swallowing errors gracefully. + + Wrapper around fetch_hooks_from_agent_server that catches all errors + and returns None. Use this for the conversation-start path where hooks + are optional and failures should not block startup. + + For the hooks viewer endpoint, use fetch_hooks_from_agent_server directly + so errors can be surfaced to the user. + + Args: + agent_server_url: URL of the agent server (e.g., 'http://localhost:8000') + session_api_key: Session API key for authentication (optional) + project_dir: Workspace directory path for project hooks + httpx_client: Shared HTTP client for making the request + + Returns: + HookConfig if hooks.json exists and is valid, None otherwise. + """ + try: + return await fetch_hooks_from_agent_server( + agent_server_url, session_api_key, project_dir, httpx_client + ) + except httpx.HTTPStatusError as e: + _logger.warning( + f'Agent-server at {agent_server_url} returned error status {e.response.status_code} ' + f'when loading hooks from {project_dir}: {e.response.text}' + ) + return None + except httpx.RequestError as e: + _logger.warning( + f'Failed to connect to agent-server at {agent_server_url} ' + f'when loading hooks from {project_dir}: {e}' + ) + return None + except Exception as e: + _logger.warning( + f'Failed to load hooks from agent-server at {agent_server_url} ' + f'for project {project_dir}: {e}' + ) + return None diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index fe07f205c1..902cde7771 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -46,6 +46,9 @@ from openhands.app_server.app_conversation.app_conversation_service_base import from openhands.app_server.app_conversation.app_conversation_start_task_service import ( AppConversationStartTaskService, ) +from openhands.app_server.app_conversation.hook_loader import ( + load_hooks_from_agent_server, +) from openhands.app_server.app_conversation.sql_app_conversation_info_service import ( SQLAppConversationInfoService, ) @@ -84,6 +87,7 @@ from openhands.app_server.utils.llm_metadata import ( from openhands.integrations.provider import ProviderType from openhands.integrations.service_types import SuggestedTask from openhands.sdk import Agent, AgentContext, LocalWorkspace +from openhands.sdk.hooks import HookConfig from openhands.sdk.llm import LLM from openhands.sdk.plugin import PluginSource from openhands.sdk.secret import LookupSecret, SecretValue, StaticSecret @@ -312,6 +316,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase): body_json = start_conversation_request.model_dump( mode='json', context={'expose_secrets': True} ) + # Log hook_config to verify it's being passed + hook_config_in_request = body_json.get('hook_config') + _logger.debug( + f'Sending StartConversationRequest with hook_config: ' + f'{hook_config_in_request}' + ) response = await self.httpx_client.post( f'{agent_server_url}/api/conversations', json=body_json, @@ -1295,6 +1305,46 @@ class LiveStatusAppConversationService(AppConversationServiceBase): run=initial_message.run, ) + async def _load_hooks_from_workspace( + self, + remote_workspace: AsyncRemoteWorkspace, + project_dir: str, + ) -> HookConfig | None: + """Load hooks from .openhands/hooks.json in the remote workspace. + + This enables project-level hooks to be automatically loaded when starting + a conversation, similar to how OpenHands-CLI loads hooks from the workspace. + + Uses the agent-server's /api/hooks endpoint, consistent with how skills + are loaded via /api/skills. + + Args: + remote_workspace: AsyncRemoteWorkspace for accessing the agent server + project_dir: Project root directory path in the sandbox. This should + already be the resolved project directory (e.g., + {working_dir}/{repo_name} when a repo is selected). + + Returns: + HookConfig if hooks.json exists and is valid, None otherwise. + Returns None in the following cases: + - hooks.json file does not exist + - hooks.json contains invalid JSON + - hooks.json contains an empty hooks configuration + - Agent server is unreachable or returns an error + + Note: + This method implements graceful degradation - if hooks cannot be loaded + for any reason, it returns None rather than raising an exception. This + ensures that conversation startup is not blocked by hook loading failures. + Errors are logged as warnings for debugging purposes. + """ + return await load_hooks_from_agent_server( + agent_server_url=remote_workspace.host, + session_api_key=remote_workspace._headers.get('X-Session-API-Key'), + project_dir=project_dir, + httpx_client=self.httpx_client, + ) + async def _finalize_conversation_request( self, agent: Agent, @@ -1334,6 +1384,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): agent = self._update_agent_with_llm_metadata(agent, conversation_id, user.id) # Load and merge skills if remote workspace is available + hook_config: HookConfig | None = None if remote_workspace: try: agent = await self._load_skills_and_update_agent( @@ -1343,6 +1394,28 @@ class LiveStatusAppConversationService(AppConversationServiceBase): _logger.warning(f'Failed to load skills: {e}', exc_info=True) # Continue without skills - don't fail conversation startup + # Load hooks from workspace (.openhands/hooks.json) + # Note: working_dir is already the resolved project_dir + # (includes repo name when a repo is selected), so we pass + # it directly without appending the repo name again. + try: + _logger.debug( + f'Attempting to load hooks from workspace: ' + f'project_dir={working_dir}' + ) + hook_config = await self._load_hooks_from_workspace( + remote_workspace, working_dir + ) + if hook_config: + _logger.debug( + f'Successfully loaded hooks: {hook_config.model_dump()}' + ) + else: + _logger.debug('No hooks found in workspace') + except Exception as e: + _logger.warning(f'Failed to load hooks: {e}', exc_info=True) + # Continue without hooks - don't fail conversation startup + # Incorporate plugin parameters into initial message if specified final_initial_message = self._construct_initial_message_with_plugin_params( initial_message, plugins @@ -1371,6 +1444,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): initial_message=final_initial_message, secrets=secrets, plugins=sdk_plugins, + hook_config=hook_config, ) async def _build_start_conversation_request_for_user( diff --git a/tests/unit/app_server/test_app_conversation_hooks_endpoint.py b/tests/unit/app_server/test_app_conversation_hooks_endpoint.py new file mode 100644 index 0000000000..ba67c4b488 --- /dev/null +++ b/tests/unit/app_server/test_app_conversation_hooks_endpoint.py @@ -0,0 +1,293 @@ +"""Unit tests for the V1 hooks endpoint in app_conversation_router. + +This module tests the GET /{conversation_id}/hooks endpoint functionality. +""" + +from unittest.mock import AsyncMock, MagicMock, Mock +from uuid import uuid4 + +import httpx +import pytest +from fastapi import status + +from openhands.app_server.app_conversation.app_conversation_models import ( + AppConversation, +) +from openhands.app_server.app_conversation.app_conversation_router import ( + get_conversation_hooks, +) +from openhands.app_server.sandbox.sandbox_models import ( + AGENT_SERVER, + ExposedUrl, + SandboxInfo, + SandboxStatus, +) +from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo + + +@pytest.mark.asyncio +class TestGetConversationHooks: + async def test_get_hooks_returns_hook_events(self): + conversation_id = uuid4() + sandbox_id = str(uuid4()) + working_dir = '/workspace' + + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test-user', + sandbox_id=sandbox_id, + selected_repository='owner/repo', + sandbox_status=SandboxStatus.RUNNING, + ) + + mock_sandbox = SandboxInfo( + id=sandbox_id, + created_by_user_id='test-user', + status=SandboxStatus.RUNNING, + sandbox_spec_id=str(uuid4()), + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://agent-server:8000', port=8000) + ], + ) + + mock_sandbox_spec = SandboxSpecInfo( + id=str(uuid4()), command=None, working_dir=working_dir + ) + + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) + + mock_sandbox_spec_service = MagicMock() + mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( + return_value=mock_sandbox_spec + ) + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.raise_for_status = Mock() + mock_response.json.return_value = { + 'hook_config': { + 'stop': [ + { + 'matcher': '*', + 'hooks': [ + { + 'type': 'command', + 'command': '.openhands/hooks/on_stop.sh', + 'timeout': 60, + 'async': True, + } + ], + } + ] + } + } + + mock_httpx_client = AsyncMock(spec=httpx.AsyncClient) + mock_httpx_client.post = AsyncMock(return_value=mock_response) + + response = await get_conversation_hooks( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + httpx_client=mock_httpx_client, + ) + + assert response.status_code == status.HTTP_200_OK + + data = __import__('json').loads(response.body.decode('utf-8')) + assert 'hooks' in data + assert data['hooks'] + assert data['hooks'][0]['event_type'] == 'stop' + assert data['hooks'][0]['matchers'][0]['matcher'] == '*' + assert data['hooks'][0]['matchers'][0]['hooks'][0]['type'] == 'command' + assert ( + data['hooks'][0]['matchers'][0]['hooks'][0]['command'] + == '.openhands/hooks/on_stop.sh' + ) + assert data['hooks'][0]['matchers'][0]['hooks'][0]['async'] is True + assert 'async_' not in data['hooks'][0]['matchers'][0]['hooks'][0] + + mock_httpx_client.post.assert_called_once() + called_url = mock_httpx_client.post.call_args[0][0] + assert called_url == 'http://agent-server:8000/api/hooks' + + async def test_get_hooks_returns_502_when_agent_server_unreachable(self): + conversation_id = uuid4() + sandbox_id = str(uuid4()) + + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test-user', + sandbox_id=sandbox_id, + selected_repository=None, + sandbox_status=SandboxStatus.RUNNING, + ) + + mock_sandbox = SandboxInfo( + id=sandbox_id, + created_by_user_id='test-user', + status=SandboxStatus.RUNNING, + sandbox_spec_id=str(uuid4()), + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://agent-server:8000', port=8000) + ], + ) + + mock_sandbox_spec = SandboxSpecInfo( + id=str(uuid4()), command=None, working_dir='/workspace' + ) + + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) + + mock_sandbox_spec_service = MagicMock() + mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( + return_value=mock_sandbox_spec + ) + + mock_httpx_client = AsyncMock(spec=httpx.AsyncClient) + + def _raise_request_error(*args, **_kwargs): + request = httpx.Request('POST', args[0]) + raise httpx.RequestError('Connection error', request=request) + + mock_httpx_client.post = AsyncMock(side_effect=_raise_request_error) + + response = await get_conversation_hooks( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + httpx_client=mock_httpx_client, + ) + + assert response.status_code == status.HTTP_502_BAD_GATEWAY + data = __import__('json').loads(response.body.decode('utf-8')) + assert 'error' in data + + async def test_get_hooks_returns_502_when_agent_server_returns_error(self): + conversation_id = uuid4() + sandbox_id = str(uuid4()) + + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test-user', + sandbox_id=sandbox_id, + selected_repository=None, + sandbox_status=SandboxStatus.RUNNING, + ) + + mock_sandbox = SandboxInfo( + id=sandbox_id, + created_by_user_id='test-user', + status=SandboxStatus.RUNNING, + sandbox_spec_id=str(uuid4()), + session_api_key='test-api-key', + exposed_urls=[ + ExposedUrl(name=AGENT_SERVER, url='http://agent-server:8000', port=8000) + ], + ) + + mock_sandbox_spec = SandboxSpecInfo( + id=str(uuid4()), command=None, working_dir='/workspace' + ) + + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) + + mock_sandbox_spec_service = MagicMock() + mock_sandbox_spec_service.get_sandbox_spec = AsyncMock( + return_value=mock_sandbox_spec + ) + + mock_httpx_client = AsyncMock(spec=httpx.AsyncClient) + + mock_response = Mock() + mock_response.status_code = 500 + + def _raise_http_status_error(*args, **_kwargs): + request = httpx.Request('POST', args[0]) + response = httpx.Response(status_code=500, text='Internal Server Error') + raise httpx.HTTPStatusError( + 'Server error', request=request, response=response + ) + + mock_httpx_client.post = AsyncMock(side_effect=_raise_http_status_error) + + response = await get_conversation_hooks( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=mock_sandbox_spec_service, + httpx_client=mock_httpx_client, + ) + + assert response.status_code == status.HTTP_502_BAD_GATEWAY + data = __import__('json').loads(response.body.decode('utf-8')) + assert 'error' in data + + async def test_get_hooks_returns_404_when_conversation_not_found(self): + conversation_id = uuid4() + + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=None + ) + + response = await get_conversation_hooks( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=MagicMock(), + sandbox_spec_service=MagicMock(), + httpx_client=AsyncMock(spec=httpx.AsyncClient), + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + async def test_get_hooks_returns_404_when_sandbox_not_running(self): + conversation_id = uuid4() + sandbox_id = str(uuid4()) + + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test-user', + sandbox_id=sandbox_id, + sandbox_status=SandboxStatus.RUNNING, + ) + + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=None) + + response = await get_conversation_hooks( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=MagicMock(), + httpx_client=AsyncMock(spec=httpx.AsyncClient), + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index fcb251797f..27b0c704c9 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -123,6 +123,10 @@ class TestLiveStatusAppConversationService: self.mock_sandbox.id = uuid4() self.mock_sandbox.status = SandboxStatus.RUNNING + # Default mock for hooks loading - returns None (no hooks found) + # Tests that specifically test hooks loading can override this mock + self.service._load_hooks_from_workspace = AsyncMock(return_value=None) + def test_apply_suggested_task_sets_prompt_and_trigger(self): """Test suggested task prompts populate initial message and trigger.""" suggested_task = SuggestedTask( @@ -179,6 +183,7 @@ class TestLiveStatusAppConversationService: with pytest.raises(ValueError, match='empty prompt'): self.service._apply_suggested_task(request) + @pytest.mark.asyncio async def test_setup_secrets_for_git_providers_no_provider_tokens(self): """Test _setup_secrets_for_git_providers with no provider tokens.""" # Arrange @@ -1139,6 +1144,8 @@ class TestLiveStatusAppConversationService: side_effect=Exception('Skills loading failed') ) + # Note: hooks loading is already mocked in setup_method() to return None + # Act with patch( 'openhands.app_server.app_conversation.live_status_app_conversation_service._logger' @@ -3144,3 +3151,275 @@ class TestAppConversationStartRequestWithPlugins: assert request.plugins[0].source == 'github:owner/plugin1' assert request.plugins[1].repo_path == 'plugins/sub' assert request.plugins[2].source == '/local/path' + + +class TestLoadHooksFromWorkspace: + """Test cases for _load_hooks_from_workspace method.""" + + def setup_method(self): + """Set up test fixtures.""" + # Create mock dependencies + self.mock_user_context = Mock(spec=UserContext) + self.mock_jwt_service = Mock() + self.mock_sandbox_service = Mock() + self.mock_sandbox_spec_service = Mock() + self.mock_app_conversation_info_service = Mock() + self.mock_app_conversation_start_task_service = Mock() + self.mock_event_callback_service = Mock() + self.mock_event_service = Mock() + self.mock_httpx_client = AsyncMock() + + # Create service instance + self.service = LiveStatusAppConversationService( + init_git_in_empty_workspace=True, + user_context=self.mock_user_context, + app_conversation_info_service=self.mock_app_conversation_info_service, + app_conversation_start_task_service=self.mock_app_conversation_start_task_service, + event_callback_service=self.mock_event_callback_service, + event_service=self.mock_event_service, + sandbox_service=self.mock_sandbox_service, + sandbox_spec_service=self.mock_sandbox_spec_service, + jwt_service=self.mock_jwt_service, + sandbox_startup_timeout=30, + sandbox_startup_poll_frequency=1, + httpx_client=self.mock_httpx_client, + web_url='https://test.example.com', + openhands_provider_base_url='https://provider.example.com', + access_token_hard_timeout=None, + app_mode='test', + ) + + @pytest.mark.asyncio + async def test_load_hooks_from_workspace_success(self): + """Test loading hooks from workspace when hooks.json exists.""" + # Arrange + mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace) + mock_remote_workspace.host = 'http://agent-server:8000' + mock_remote_workspace._headers = {'X-Session-API-Key': 'test-key'} + + hooks_response = { + 'hook_config': { + 'stop': [ + { + 'matcher': '*', + 'hooks': [{'type': 'command', 'command': 'echo "stop hook"'}], + } + ] + } + } + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = hooks_response + mock_response.raise_for_status = Mock() + + self.mock_httpx_client.post = AsyncMock(return_value=mock_response) + + # Act + result = await self.service._load_hooks_from_workspace( + mock_remote_workspace, '/workspace' + ) + + # Assert + assert result is not None + assert not result.is_empty() + self.mock_httpx_client.post.assert_called_once_with( + 'http://agent-server:8000/api/hooks', + json={'project_dir': '/workspace'}, + headers={ + 'Content-Type': 'application/json', + 'X-Session-API-Key': 'test-key', + }, + timeout=30.0, + ) + + @pytest.mark.asyncio + async def test_load_hooks_from_workspace_file_not_found(self): + """Test loading hooks when hooks.json does not exist.""" + # Arrange + mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace) + mock_remote_workspace.host = 'http://agent-server:8000' + mock_remote_workspace._headers = {} + + # Agent server returns hook_config: None when file not found + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {'hook_config': None} + mock_response.raise_for_status = Mock() + + self.mock_httpx_client.post = AsyncMock(return_value=mock_response) + + # Act + result = await self.service._load_hooks_from_workspace( + mock_remote_workspace, '/workspace' + ) + + # Assert + assert result is None + + @pytest.mark.asyncio + async def test_load_hooks_from_workspace_empty_hooks(self): + """Test loading hooks when hooks.json is empty or has no hooks.""" + # Arrange + mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace) + mock_remote_workspace.host = 'http://agent-server:8000' + mock_remote_workspace._headers = {} + + # Agent server returns empty hook_config + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {'hook_config': {}} + mock_response.raise_for_status = Mock() + + self.mock_httpx_client.post = AsyncMock(return_value=mock_response) + + # Act + result = await self.service._load_hooks_from_workspace( + mock_remote_workspace, '/workspace' + ) + + # Assert + assert result is None + + @pytest.mark.asyncio + async def test_load_hooks_from_workspace_http_error(self): + """Test loading hooks when HTTP request fails.""" + # Arrange + mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace) + mock_remote_workspace.host = 'http://agent-server:8000' + mock_remote_workspace._headers = {} + + self.mock_httpx_client.post = AsyncMock( + side_effect=Exception('Connection error') + ) + + # Act + result = await self.service._load_hooks_from_workspace( + mock_remote_workspace, '/workspace' + ) + + # Assert + assert result is None + + def test_get_project_dir_for_hooks_with_selected_repository(self): + """Test get_project_dir_for_hooks with a selected repository.""" + from openhands.app_server.app_conversation.hook_loader import ( + get_project_dir_for_hooks, + ) + + result = get_project_dir_for_hooks( + '/workspace/project', + 'OpenHands/software-agent-sdk', + ) + assert result == '/workspace/project/software-agent-sdk' + + def test_get_project_dir_for_hooks_without_selected_repository(self): + """Test get_project_dir_for_hooks without a selected repository.""" + from openhands.app_server.app_conversation.hook_loader import ( + get_project_dir_for_hooks, + ) + + result = get_project_dir_for_hooks('/workspace/project', None) + assert result == '/workspace/project' + + def test_get_project_dir_for_hooks_with_empty_string(self): + """Test get_project_dir_for_hooks with empty string repository.""" + from openhands.app_server.app_conversation.hook_loader import ( + get_project_dir_for_hooks, + ) + + # Empty string should be treated as no repository + result = get_project_dir_for_hooks('/workspace/project', '') + assert result == '/workspace/project' + + @pytest.mark.asyncio + async def test_load_hooks_from_workspace_with_project_dir(self): + """Test loading hooks with a pre-resolved project_dir. + + The caller is responsible for computing the project_dir (which + already includes the repo name when a repo is selected). + _load_hooks_from_workspace should use the project_dir as-is. + """ + # Arrange + mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace) + mock_remote_workspace.host = 'http://agent-server:8000' + mock_remote_workspace._headers = {'X-Session-API-Key': 'test-key'} + + hooks_response = { + 'hook_config': { + 'stop': [ + { + 'matcher': '*', + 'hooks': [{'type': 'command', 'command': 'echo "stop hook"'}], + } + ] + } + } + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = hooks_response + mock_response.raise_for_status = Mock() + + self.mock_httpx_client.post = AsyncMock(return_value=mock_response) + + # Act - project_dir already includes repo name + result = await self.service._load_hooks_from_workspace( + mock_remote_workspace, + '/workspace/project/software-agent-sdk', + ) + + # Assert + assert result is not None + assert not result.is_empty() + # The project_dir should be passed as-is without doubling + self.mock_httpx_client.post.assert_called_once_with( + 'http://agent-server:8000/api/hooks', + json={'project_dir': '/workspace/project/software-agent-sdk'}, + headers={ + 'Content-Type': 'application/json', + 'X-Session-API-Key': 'test-key', + }, + timeout=30.0, + ) + + @pytest.mark.asyncio + async def test_load_hooks_from_workspace_base_dir(self): + """Test loading hooks with a base workspace directory (no repo selected).""" + # Arrange + mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace) + mock_remote_workspace.host = 'http://agent-server:8000' + mock_remote_workspace._headers = {'X-Session-API-Key': 'test-key'} + + hooks_response = { + 'hook_config': { + 'stop': [ + { + 'matcher': '*', + 'hooks': [{'type': 'command', 'command': 'echo "stop hook"'}], + } + ] + } + } + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = hooks_response + mock_response.raise_for_status = Mock() + + self.mock_httpx_client.post = AsyncMock(return_value=mock_response) + + # Act - no repo selected, project_dir is base working_dir + result = await self.service._load_hooks_from_workspace( + mock_remote_workspace, + '/workspace/project', + ) + + # Assert + assert result is not None + self.mock_httpx_client.post.assert_called_once_with( + 'http://agent-server:8000/api/hooks', + json={'project_dir': '/workspace/project'}, + headers={ + 'Content-Type': 'application/json', + 'X-Session-API-Key': 'test-key', + }, + timeout=30.0, + ) From 55e4f0720042aaa5c108691eff59890a92f5f27e Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Tue, 17 Mar 2026 02:49:36 +0800 Subject: [PATCH 34/92] fix: add missing params to TestLoadHooksFromWorkspace setup (#13424) Co-authored-by: openhands --- .../app_server/test_live_status_app_conversation_service.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index 27b0c704c9..9a57b9b652 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -3168,6 +3168,7 @@ class TestLoadHooksFromWorkspace: self.mock_event_callback_service = Mock() self.mock_event_service = Mock() self.mock_httpx_client = AsyncMock() + self.mock_pending_message_service = Mock() # Create service instance self.service = LiveStatusAppConversationService( @@ -3180,8 +3181,10 @@ class TestLoadHooksFromWorkspace: sandbox_service=self.mock_sandbox_service, sandbox_spec_service=self.mock_sandbox_spec_service, jwt_service=self.mock_jwt_service, + pending_message_service=self.mock_pending_message_service, sandbox_startup_timeout=30, sandbox_startup_poll_frequency=1, + max_num_conversations_per_sandbox=20, httpx_client=self.mock_httpx_client, web_url='https://test.example.com', openhands_provider_base_url='https://provider.example.com', From 934fbe93c262605d3b0613d62beaeccbcab20008 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Mon, 16 Mar 2026 14:54:36 -0400 Subject: [PATCH 35/92] Feat: enterprise banner option during device oauth (#13361) Co-authored-by: openhands --- .../device-verify/enterprise-banner.test.tsx | 118 ++++ .../__tests__/routes/device-verify.test.tsx | 659 ++++++++++++++++++ .../device-verify/enterprise-banner.tsx | 69 ++ frontend/src/i18n/declaration.ts | 30 + frontend/src/i18n/translation.json | 480 +++++++++++++ frontend/src/icons/check-circle-fill.svg | 3 + frontend/src/routes/device-verify.tsx | 148 ++-- frontend/src/utils/feature-flags.ts | 1 + 8 files changed, 1433 insertions(+), 75 deletions(-) create mode 100644 frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx create mode 100644 frontend/__tests__/routes/device-verify.test.tsx create mode 100644 frontend/src/components/features/device-verify/enterprise-banner.tsx create mode 100644 frontend/src/icons/check-circle-fill.svg diff --git a/frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx b/frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx new file mode 100644 index 0000000000..2568601423 --- /dev/null +++ b/frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx @@ -0,0 +1,118 @@ +import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "test-utils"; +import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner"; + +const mockCapture = vi.fn(); +vi.mock("posthog-js/react", () => ({ + usePostHog: () => ({ + capture: mockCapture, + }), +})); + +const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({ + PROJ_USER_JOURNEY_MOCK: vi.fn(() => true), +})); + +vi.mock("#/utils/feature-flags", () => ({ + PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(), +})); + +describe("EnterpriseBanner", () => { + beforeEach(() => { + vi.clearAllMocks(); + PROJ_USER_JOURNEY_MOCK.mockReturnValue(true); + }); + + describe("Feature Flag", () => { + it("should not render when proj_user_journey feature flag is disabled", () => { + PROJ_USER_JOURNEY_MOCK.mockReturnValue(false); + + const { container } = renderWithProviders(); + + expect(container.firstChild).toBeNull(); + expect(screen.queryByText("ENTERPRISE$TITLE")).not.toBeInTheDocument(); + }); + + it("should render when proj_user_journey feature flag is enabled", () => { + PROJ_USER_JOURNEY_MOCK.mockReturnValue(true); + + renderWithProviders(); + + expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument(); + }); + }); + + describe("Rendering", () => { + it("should render the self-hosted label", () => { + renderWithProviders(); + + expect(screen.getByText("ENTERPRISE$SELF_HOSTED")).toBeInTheDocument(); + }); + + it("should render the enterprise title", () => { + renderWithProviders(); + + expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument(); + }); + + it("should render the enterprise description", () => { + renderWithProviders(); + + expect(screen.getByText("ENTERPRISE$DESCRIPTION")).toBeInTheDocument(); + }); + + it("should render all four enterprise feature items", () => { + renderWithProviders(); + + expect( + screen.getByText("ENTERPRISE$FEATURE_DATA_PRIVACY"), + ).toBeInTheDocument(); + expect( + screen.getByText("ENTERPRISE$FEATURE_DEPLOYMENT"), + ).toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$FEATURE_SSO")).toBeInTheDocument(); + expect( + screen.getByText("ENTERPRISE$FEATURE_SUPPORT"), + ).toBeInTheDocument(); + }); + + it("should render the learn more link", () => { + renderWithProviders(); + + const link = screen.getByRole("link", { + name: "ENTERPRISE$LEARN_MORE_ARIA", + }); + expect(link).toBeInTheDocument(); + expect(link).toHaveTextContent("ENTERPRISE$LEARN_MORE"); + expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + + describe("Learn More Link Interaction", () => { + it("should capture PostHog event when learn more link is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const link = screen.getByRole("link", { + name: "ENTERPRISE$LEARN_MORE_ARIA", + }); + await user.click(link); + + expect(mockCapture).toHaveBeenCalledWith("saas_selfhosted_inquiry"); + }); + + it("should have correct href attribute for opening in new tab", () => { + renderWithProviders(); + + const link = screen.getByRole("link", { + name: "ENTERPRISE$LEARN_MORE_ARIA", + }); + expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise"); + expect(link).toHaveAttribute("target", "_blank"); + }); + }); +}); diff --git a/frontend/__tests__/routes/device-verify.test.tsx b/frontend/__tests__/routes/device-verify.test.tsx new file mode 100644 index 0000000000..47773ddbf5 --- /dev/null +++ b/frontend/__tests__/routes/device-verify.test.tsx @@ -0,0 +1,659 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createRoutesStub } from "react-router"; +import DeviceVerify from "#/routes/device-verify"; + +const { useIsAuthedMock, PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({ + useIsAuthedMock: vi.fn(() => ({ + data: false as boolean | undefined, + isLoading: false, + })), + PROJ_USER_JOURNEY_MOCK: vi.fn(() => true), +})); + +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => useIsAuthedMock(), +})); + +vi.mock("posthog-js/react", () => ({ + usePostHog: () => ({ + capture: vi.fn(), + }), +})); + +vi.mock("#/utils/feature-flags", () => ({ + PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(), +})); + +const RouterStub = createRoutesStub([ + { + Component: DeviceVerify, + path: "/device-verify", + }, +]); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); + } + + return Wrapper; +}; + +describe("DeviceVerify", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("close", vi.fn()); + // Mock fetch for API calls + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }), + ), + ); + // Enable feature flag by default + PROJ_USER_JOURNEY_MOCK.mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe("Loading State", () => { + it("should show loading spinner while checking authentication", async () => { + useIsAuthedMock.mockReturnValue({ + data: undefined, + isLoading: true, + }); + + render(, { + wrapper: createWrapper(), + }); + + await waitFor(() => { + const spinner = document.querySelector(".animate-spin"); + expect(spinner).toBeInTheDocument(); + }); + + expect(screen.getByText("DEVICE$PROCESSING")).toBeInTheDocument(); + }); + }); + + describe("Not Authenticated State", () => { + it("should show authentication required message when not authenticated", async () => { + useIsAuthedMock.mockReturnValue({ + data: false, + isLoading: false, + }); + + render(, { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(screen.getByText("DEVICE$AUTH_REQUIRED")).toBeInTheDocument(); + }); + + expect(screen.getByText("DEVICE$SIGN_IN_PROMPT")).toBeInTheDocument(); + }); + }); + + describe("Authenticated without User Code", () => { + it("should show manual code entry form when authenticated but no code in URL", async () => { + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + render(, { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect( + screen.getByText("DEVICE$AUTHORIZATION_TITLE"), + ).toBeInTheDocument(); + }); + + expect(screen.getByText("DEVICE$ENTER_CODE_PROMPT")).toBeInTheDocument(); + expect(screen.getByLabelText("DEVICE$CODE_INPUT_LABEL")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "DEVICE$CONTINUE" }), + ).toBeInTheDocument(); + }); + + it("should submit manually entered code", async () => { + const user = userEvent.setup(); + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + const mockFetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }), + ); + vi.stubGlobal("fetch", mockFetch); + + render(, { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(screen.getByLabelText("DEVICE$CODE_INPUT_LABEL")).toBeInTheDocument(); + }); + + const input = screen.getByLabelText("DEVICE$CODE_INPUT_LABEL"); + await user.type(input, "TESTCODE"); + + const submitButton = screen.getByRole("button", { + name: "DEVICE$CONTINUE", + }); + await user.click(submitButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + "/oauth/device/verify-authenticated", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "user_code=TESTCODE", + credentials: "include", + }), + ); + }); + }); + }); + + describe("Authenticated with User Code", () => { + it("should show authorization confirmation when authenticated with code in URL", async () => { + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByText("DEVICE$AUTHORIZATION_REQUEST"), + ).toBeInTheDocument(); + }); + + expect(screen.getByText("DEVICE$CODE_LABEL")).toBeInTheDocument(); + expect(screen.getByText("ABC-123")).toBeInTheDocument(); + expect(screen.getByText("DEVICE$SECURITY_NOTICE")).toBeInTheDocument(); + expect(screen.getByText("DEVICE$SECURITY_WARNING")).toBeInTheDocument(); + expect(screen.getByText("DEVICE$CONFIRM_PROMPT")).toBeInTheDocument(); + }); + + it("should show cancel and authorize buttons", async () => { + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$CANCEL" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), + ).toBeInTheDocument(); + }); + }); + + it("should include the EnterpriseBanner component when feature flag is enabled", async () => { + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument(); + }); + }); + + it("should not include the EnterpriseBanner and be center-aligned when feature flag is disabled", async () => { + PROJ_USER_JOURNEY_MOCK.mockReturnValue(false); + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByText("DEVICE$AUTHORIZATION_REQUEST"), + ).toBeInTheDocument(); + }); + + // Banner should not be rendered + expect(screen.queryByText("ENTERPRISE$TITLE")).not.toBeInTheDocument(); + + // Container should use max-w-md (centered layout) instead of max-w-4xl + const container = document.querySelector(".max-w-md"); + expect(container).toBeInTheDocument(); + expect(document.querySelector(".max-w-4xl")).not.toBeInTheDocument(); + + // Authorization card should have mx-auto for centering + const authCard = container?.querySelector(".mx-auto"); + expect(authCard).toBeInTheDocument(); + }); + + it("should call window.close when cancel button is clicked", async () => { + const user = userEvent.setup(); + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$CANCEL" }), + ).toBeInTheDocument(); + }); + + const cancelButton = screen.getByRole("button", { name: "DEVICE$CANCEL" }); + await user.click(cancelButton); + + expect(window.close).toHaveBeenCalled(); + }); + + it("should submit device verification when authorize button is clicked", async () => { + const user = userEvent.setup(); + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + const mockFetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }), + ); + vi.stubGlobal("fetch", mockFetch); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), + ).toBeInTheDocument(); + }); + + const authorizeButton = screen.getByRole("button", { + name: "DEVICE$AUTHORIZE", + }); + await user.click(authorizeButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + "/oauth/device/verify-authenticated", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "user_code=ABC-123", + credentials: "include", + }), + ); + }); + }); + }); + + describe("Processing State", () => { + it("should show processing spinner during verification", async () => { + const user = userEvent.setup(); + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + // Make fetch hang to show processing state + const mockFetch = vi.fn(() => new Promise(() => {})); + vi.stubGlobal("fetch", mockFetch); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), + ).toBeInTheDocument(); + }); + + const authorizeButton = screen.getByRole("button", { + name: "DEVICE$AUTHORIZE", + }); + await user.click(authorizeButton); + + await waitFor(() => { + const spinner = document.querySelector(".animate-spin"); + expect(spinner).toBeInTheDocument(); + expect(screen.getByText("DEVICE$PROCESSING")).toBeInTheDocument(); + }); + }); + }); + + describe("Success State", () => { + it("should show success message after successful verification", async () => { + const user = userEvent.setup(); + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + const mockFetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }), + ); + vi.stubGlobal("fetch", mockFetch); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), + ).toBeInTheDocument(); + }); + + const authorizeButton = screen.getByRole("button", { + name: "DEVICE$AUTHORIZE", + }); + await user.click(authorizeButton); + + await waitFor(() => { + expect(screen.getByText("DEVICE$SUCCESS_TITLE")).toBeInTheDocument(); + }); + + expect(screen.getByText("DEVICE$SUCCESS_MESSAGE")).toBeInTheDocument(); + // Should show success icon (checkmark) + const successIcon = document.querySelector(".text-green-600"); + expect(successIcon).toBeInTheDocument(); + }); + + it("should not show try again button on success", async () => { + const user = userEvent.setup(); + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + const mockFetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }), + ); + vi.stubGlobal("fetch", mockFetch); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), + ).toBeInTheDocument(); + }); + + const authorizeButton = screen.getByRole("button", { + name: "DEVICE$AUTHORIZE", + }); + await user.click(authorizeButton); + + await waitFor(() => { + expect(screen.getByText("DEVICE$SUCCESS_TITLE")).toBeInTheDocument(); + }); + + expect( + screen.queryByRole("button", { name: "DEVICE$TRY_AGAIN" }), + ).not.toBeInTheDocument(); + }); + }); + + describe("Error State", () => { + it("should show error message when verification fails with non-ok response", async () => { + const user = userEvent.setup(); + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + const mockFetch = vi.fn(() => + Promise.resolve({ + ok: false, + status: 400, + json: () => Promise.resolve({ error: "invalid_code" }), + }), + ); + vi.stubGlobal("fetch", mockFetch); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), + ).toBeInTheDocument(); + }); + + const authorizeButton = screen.getByRole("button", { + name: "DEVICE$AUTHORIZE", + }); + await user.click(authorizeButton); + + await waitFor(() => { + expect(screen.getByText("DEVICE$ERROR_TITLE")).toBeInTheDocument(); + }); + + expect(screen.getByText("DEVICE$ERROR_FAILED")).toBeInTheDocument(); + // Should show error icon (X) + const errorIcon = document.querySelector(".text-red-600"); + expect(errorIcon).toBeInTheDocument(); + }); + + it("should show error message when fetch throws an exception", async () => { + const user = userEvent.setup(); + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + const mockFetch = vi.fn(() => Promise.reject(new Error("Network error"))); + vi.stubGlobal("fetch", mockFetch); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), + ).toBeInTheDocument(); + }); + + const authorizeButton = screen.getByRole("button", { + name: "DEVICE$AUTHORIZE", + }); + await user.click(authorizeButton); + + await waitFor(() => { + expect(screen.getByText("DEVICE$ERROR_TITLE")).toBeInTheDocument(); + }); + + expect(screen.getByText("DEVICE$ERROR_OCCURRED")).toBeInTheDocument(); + }); + + it("should show try again button on error", async () => { + const user = userEvent.setup(); + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + const mockFetch = vi.fn(() => + Promise.resolve({ + ok: false, + status: 400, + }), + ); + vi.stubGlobal("fetch", mockFetch); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), + ).toBeInTheDocument(); + }); + + const authorizeButton = screen.getByRole("button", { + name: "DEVICE$AUTHORIZE", + }); + await user.click(authorizeButton); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$TRY_AGAIN" }), + ).toBeInTheDocument(); + }); + }); + + it("should reload page when try again button is clicked", async () => { + const user = userEvent.setup(); + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + }); + + const mockFetch = vi.fn(() => + Promise.resolve({ + ok: false, + status: 400, + }), + ); + vi.stubGlobal("fetch", mockFetch); + + const reloadMock = vi.fn(); + vi.stubGlobal("location", { reload: reloadMock }); + + render( + , + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), + ).toBeInTheDocument(); + }); + + const authorizeButton = screen.getByRole("button", { + name: "DEVICE$AUTHORIZE", + }); + await user.click(authorizeButton); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "DEVICE$TRY_AGAIN" }), + ).toBeInTheDocument(); + }); + + const tryAgainButton = screen.getByRole("button", { + name: "DEVICE$TRY_AGAIN", + }); + await user.click(tryAgainButton); + + expect(reloadMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/components/features/device-verify/enterprise-banner.tsx b/frontend/src/components/features/device-verify/enterprise-banner.tsx new file mode 100644 index 0000000000..7746bac48d --- /dev/null +++ b/frontend/src/components/features/device-verify/enterprise-banner.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from "react-i18next"; +import { usePostHog } from "posthog-js/react"; +import { I18nKey } from "#/i18n/declaration"; +import { H2, Text } from "#/ui/typography"; +import CheckCircleFillIcon from "#/icons/check-circle-fill.svg?react"; +import { PROJ_USER_JOURNEY } from "#/utils/feature-flags"; + +const ENTERPRISE_FEATURE_KEYS: I18nKey[] = [ + I18nKey.ENTERPRISE$FEATURE_DATA_PRIVACY, + I18nKey.ENTERPRISE$FEATURE_DEPLOYMENT, + I18nKey.ENTERPRISE$FEATURE_SSO, + I18nKey.ENTERPRISE$FEATURE_SUPPORT, +]; + +export function EnterpriseBanner() { + const { t } = useTranslation(); + const posthog = usePostHog(); + + if (!PROJ_USER_JOURNEY()) { + return null; + } + + const handleLearnMore = () => { + posthog?.capture("saas_selfhosted_inquiry"); + }; + + return ( +
+ {/* Self-Hosted Label */} +
+
+ + {t(I18nKey.ENTERPRISE$SELF_HOSTED)} + +
+
+ + {/* Title */} +

{t(I18nKey.ENTERPRISE$TITLE)}

+ + {/* Description */} + + {t(I18nKey.ENTERPRISE$DESCRIPTION)} + + + {/* Features List */} +
    + {ENTERPRISE_FEATURE_KEYS.map((featureKey) => ( +
  • + + {t(featureKey)} +
  • + ))} +
+ + {/* Learn More Button */} + + {t(I18nKey.ENTERPRISE$LEARN_MORE)} + +
+ ); +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 8aef562320..25bb3a7c37 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1136,4 +1136,34 @@ export enum I18nKey { ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON", ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON", ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON", + ENTERPRISE$SELF_HOSTED = "ENTERPRISE$SELF_HOSTED", + ENTERPRISE$TITLE = "ENTERPRISE$TITLE", + ENTERPRISE$DESCRIPTION = "ENTERPRISE$DESCRIPTION", + ENTERPRISE$FEATURE_DATA_PRIVACY = "ENTERPRISE$FEATURE_DATA_PRIVACY", + ENTERPRISE$FEATURE_DEPLOYMENT = "ENTERPRISE$FEATURE_DEPLOYMENT", + ENTERPRISE$FEATURE_SSO = "ENTERPRISE$FEATURE_SSO", + ENTERPRISE$FEATURE_SUPPORT = "ENTERPRISE$FEATURE_SUPPORT", + ENTERPRISE$LEARN_MORE = "ENTERPRISE$LEARN_MORE", + ENTERPRISE$LEARN_MORE_ARIA = "ENTERPRISE$LEARN_MORE_ARIA", + DEVICE$SUCCESS_TITLE = "DEVICE$SUCCESS_TITLE", + DEVICE$ERROR_TITLE = "DEVICE$ERROR_TITLE", + DEVICE$SUCCESS_MESSAGE = "DEVICE$SUCCESS_MESSAGE", + DEVICE$ERROR_FAILED = "DEVICE$ERROR_FAILED", + DEVICE$ERROR_OCCURRED = "DEVICE$ERROR_OCCURRED", + DEVICE$TRY_AGAIN = "DEVICE$TRY_AGAIN", + DEVICE$PROCESSING = "DEVICE$PROCESSING", + DEVICE$AUTHORIZATION_REQUEST = "DEVICE$AUTHORIZATION_REQUEST", + DEVICE$CODE_LABEL = "DEVICE$CODE_LABEL", + DEVICE$SECURITY_NOTICE = "DEVICE$SECURITY_NOTICE", + DEVICE$SECURITY_WARNING = "DEVICE$SECURITY_WARNING", + DEVICE$CONFIRM_PROMPT = "DEVICE$CONFIRM_PROMPT", + DEVICE$CANCEL = "DEVICE$CANCEL", + DEVICE$AUTHORIZE = "DEVICE$AUTHORIZE", + DEVICE$AUTHORIZATION_TITLE = "DEVICE$AUTHORIZATION_TITLE", + DEVICE$ENTER_CODE_PROMPT = "DEVICE$ENTER_CODE_PROMPT", + DEVICE$CODE_INPUT_LABEL = "DEVICE$CODE_INPUT_LABEL", + DEVICE$CODE_PLACEHOLDER = "DEVICE$CODE_PLACEHOLDER", + DEVICE$CONTINUE = "DEVICE$CONTINUE", + DEVICE$AUTH_REQUIRED = "DEVICE$AUTH_REQUIRED", + DEVICE$SIGN_IN_PROMPT = "DEVICE$SIGN_IN_PROMPT", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 5556f891ca..466e9b01cc 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -18174,5 +18174,485 @@ "es": "Finalizar", "tr": "Bitir", "uk": "Завершити" + }, + "ENTERPRISE$SELF_HOSTED": { + "en": "Self-Hosted", + "ja": "セルフホスト", + "zh-CN": "自托管", + "zh-TW": "自託管", + "ko-KR": "셀프 호스팅", + "no": "Selvhosted", + "ar": "مستضاف ذاتياً", + "de": "Selbst gehostet", + "fr": "Auto-hébergé", + "it": "Self-hosted", + "pt": "Auto-hospedado", + "es": "Autoalojado", + "tr": "Kendi Sunucunuzda", + "uk": "Самостійний хостинг" + }, + "ENTERPRISE$TITLE": { + "en": "OpenHands Enterprise", + "ja": "OpenHands Enterprise", + "zh-CN": "OpenHands 企业版", + "zh-TW": "OpenHands 企業版", + "ko-KR": "OpenHands 엔터프라이즈", + "no": "OpenHands Enterprise", + "ar": "OpenHands للمؤسسات", + "de": "OpenHands Enterprise", + "fr": "OpenHands Enterprise", + "it": "OpenHands Enterprise", + "pt": "OpenHands Enterprise", + "es": "OpenHands Enterprise", + "tr": "OpenHands Kurumsal", + "uk": "OpenHands Enterprise" + }, + "ENTERPRISE$DESCRIPTION": { + "en": "Complete data control with your own self-hosted AI development platform.", + "ja": "独自のセルフホストAI開発プラットフォームで完全なデータ管理を実現。", + "zh-CN": "通过自托管AI开发平台实现完全的数据控制。", + "zh-TW": "透過自託管AI開發平台實現完全的資料控制。", + "ko-KR": "셀프 호스팅 AI 개발 플랫폼으로 완벽한 데이터 제어를 실현하세요.", + "no": "Fullstendig datakontroll med din egen selvhostede AI-utviklingsplattform.", + "ar": "تحكم كامل في البيانات مع منصة تطوير الذكاء الاصطناعي المستضافة ذاتياً.", + "de": "Vollständige Datenkontrolle mit Ihrer eigenen selbst gehosteten KI-Entwicklungsplattform.", + "fr": "Contrôle total des données avec votre propre plateforme de développement IA auto-hébergée.", + "it": "Controllo completo dei dati con la tua piattaforma di sviluppo AI self-hosted.", + "pt": "Controle completo de dados com sua própria plataforma de desenvolvimento de IA auto-hospedada.", + "es": "Control completo de datos con tu propia plataforma de desarrollo de IA autoalojada.", + "tr": "Kendi barındırdığınız yapay zeka geliştirme platformuyla tam veri kontrolü.", + "uk": "Повний контроль над даними з власною самостійно розміщеною платформою розробки ШІ." + }, + "ENTERPRISE$FEATURE_DATA_PRIVACY": { + "en": "Full data privacy & control", + "ja": "完全なデータプライバシーと管理", + "zh-CN": "完全的数据隐私和控制", + "zh-TW": "完全的資料隱私和控制", + "ko-KR": "완벽한 데이터 프라이버시 및 제어", + "no": "Full datapersonvern og kontroll", + "ar": "خصوصية وتحكم كامل في البيانات", + "de": "Vollständiger Datenschutz und Kontrolle", + "fr": "Confidentialité et contrôle complets des données", + "it": "Privacy e controllo completo dei dati", + "pt": "Privacidade e controle total de dados", + "es": "Privacidad y control total de datos", + "tr": "Tam veri gizliliği ve kontrolü", + "uk": "Повна конфіденційність та контроль даних" + }, + "ENTERPRISE$FEATURE_DEPLOYMENT": { + "en": "Custom deployment options", + "ja": "カスタムデプロイオプション", + "zh-CN": "自定义部署选项", + "zh-TW": "自訂部署選項", + "ko-KR": "맞춤형 배포 옵션", + "no": "Tilpassede distribusjonsalternativer", + "ar": "خيارات نشر مخصصة", + "de": "Individuelle Bereitstellungsoptionen", + "fr": "Options de déploiement personnalisées", + "it": "Opzioni di distribuzione personalizzate", + "pt": "Opções de implantação personalizadas", + "es": "Opciones de despliegue personalizadas", + "tr": "Özel dağıtım seçenekleri", + "uk": "Налаштовані варіанти розгортання" + }, + "ENTERPRISE$FEATURE_SSO": { + "en": "SSO & enterprise auth", + "ja": "SSOとエンタープライズ認証", + "zh-CN": "SSO和企业认证", + "zh-TW": "SSO和企業認證", + "ko-KR": "SSO 및 엔터프라이즈 인증", + "no": "SSO og bedriftsautentisering", + "ar": "تسجيل دخول موحد ومصادقة المؤسسات", + "de": "SSO und Unternehmensauthentifizierung", + "fr": "SSO et authentification d'entreprise", + "it": "SSO e autenticazione aziendale", + "pt": "SSO e autenticação empresarial", + "es": "SSO y autenticación empresarial", + "tr": "SSO ve kurumsal kimlik doğrulama", + "uk": "SSO та корпоративна автентифікація" + }, + "ENTERPRISE$FEATURE_SUPPORT": { + "en": "Dedicated support", + "ja": "専用サポート", + "zh-CN": "专属支持", + "zh-TW": "專屬支援", + "ko-KR": "전담 지원", + "no": "Dedikert støtte", + "ar": "دعم مخصص", + "de": "Dedizierter Support", + "fr": "Support dédié", + "it": "Supporto dedicato", + "pt": "Suporte dedicado", + "es": "Soporte dedicado", + "tr": "Özel destek", + "uk": "Виділена підтримка" + }, + "ENTERPRISE$LEARN_MORE": { + "en": "Learn More", + "ja": "詳細を見る", + "zh-CN": "了解更多", + "zh-TW": "了解更多", + "ko-KR": "더 알아보기", + "no": "Les mer", + "ar": "اعرف المزيد", + "de": "Mehr erfahren", + "fr": "En savoir plus", + "it": "Scopri di più", + "pt": "Saiba mais", + "es": "Más información", + "tr": "Daha Fazla Bilgi", + "uk": "Дізнатися більше" + }, + "ENTERPRISE$LEARN_MORE_ARIA": { + "en": "Learn more about OpenHands Enterprise (opens in new window)", + "ja": "OpenHands Enterpriseの詳細を見る(新しいウィンドウで開く)", + "zh-CN": "了解更多关于 OpenHands 企业版的信息(在新窗口中打开)", + "zh-TW": "了解更多關於 OpenHands 企業版的資訊(在新視窗中開啟)", + "ko-KR": "OpenHands 엔터프라이즈에 대해 더 알아보기 (새 창에서 열림)", + "no": "Les mer om OpenHands Enterprise (åpnes i nytt vindu)", + "ar": "اعرف المزيد عن OpenHands Enterprise (يفتح في نافذة جديدة)", + "de": "Erfahren Sie mehr über OpenHands Enterprise (öffnet in neuem Fenster)", + "fr": "En savoir plus sur OpenHands Enterprise (s'ouvre dans une nouvelle fenêtre)", + "it": "Scopri di più su OpenHands Enterprise (si apre in una nuova finestra)", + "pt": "Saiba mais sobre OpenHands Enterprise (abre em nova janela)", + "es": "Más información sobre OpenHands Enterprise (abre en nueva ventana)", + "tr": "OpenHands Enterprise hakkında daha fazla bilgi edinin (yeni pencerede açılır)", + "uk": "Дізнатися більше про OpenHands Enterprise (відкривається в новому вікні)" + }, + "DEVICE$SUCCESS_TITLE": { + "en": "Success!", + "ja": "成功!", + "zh-CN": "成功!", + "zh-TW": "成功!", + "ko-KR": "성공!", + "no": "Suksess!", + "ar": "نجاح!", + "de": "Erfolg!", + "fr": "Succès !", + "it": "Successo!", + "pt": "Sucesso!", + "es": "¡Éxito!", + "tr": "Başarılı!", + "uk": "Успіх!" + }, + "DEVICE$ERROR_TITLE": { + "en": "Error", + "ja": "エラー", + "zh-CN": "错误", + "zh-TW": "錯誤", + "ko-KR": "오류", + "no": "Feil", + "ar": "خطأ", + "de": "Fehler", + "fr": "Erreur", + "it": "Errore", + "pt": "Erro", + "es": "Error", + "tr": "Hata", + "uk": "Помилка" + }, + "DEVICE$SUCCESS_MESSAGE": { + "en": "Device authorized successfully! You can now return to your CLI and close this window.", + "ja": "デバイスが正常に認証されました!CLIに戻り、このウィンドウを閉じてください。", + "zh-CN": "设备授权成功!您现在可以返回CLI并关闭此窗口。", + "zh-TW": "設備授權成功!您現在可以返回 CLI 並關閉此視窗。", + "ko-KR": "기기가 성공적으로 인증되었습니다! CLI로 돌아가서 이 창을 닫으세요.", + "no": "Enheten er autorisert! Du kan nå gå tilbake til CLI og lukke dette vinduet.", + "ar": "تم ترخيص الجهاز بنجاح! يمكنك الآن العودة إلى CLI وإغلاق هذه النافذة.", + "de": "Gerät erfolgreich autorisiert! Sie können jetzt zu Ihrer CLI zurückkehren und dieses Fenster schließen.", + "fr": "Appareil autorisé avec succès ! Vous pouvez maintenant retourner à votre CLI et fermer cette fenêtre.", + "it": "Dispositivo autorizzato con successo! Ora puoi tornare alla CLI e chiudere questa finestra.", + "pt": "Dispositivo autorizado com sucesso! Você pode voltar ao CLI e fechar esta janela.", + "es": "¡Dispositivo autorizado exitosamente! Ahora puedes volver a tu CLI y cerrar esta ventana.", + "tr": "Cihaz başarıyla yetkilendirildi! Artık CLI'nize dönebilir ve bu pencereyi kapatabilirsiniz.", + "uk": "Пристрій успішно авторизовано! Тепер ви можете повернутися до CLI та закрити це вікно." + }, + "DEVICE$ERROR_FAILED": { + "en": "Failed to authorize device. Please try again.", + "ja": "デバイスの認証に失敗しました。もう一度お試しください。", + "zh-CN": "设备授权失败。请重试。", + "zh-TW": "設備授權失敗。請重試。", + "ko-KR": "기기 인증에 실패했습니다. 다시 시도해 주세요.", + "no": "Kunne ikke autorisere enheten. Vennligst prøv igjen.", + "ar": "فشل في ترخيص الجهاز. يرجى المحاولة مرة أخرى.", + "de": "Geräteautorisierung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "fr": "Échec de l'autorisation de l'appareil. Veuillez réessayer.", + "it": "Autorizzazione dispositivo fallita. Riprova.", + "pt": "Falha ao autorizar o dispositivo. Por favor, tente novamente.", + "es": "Error al autorizar el dispositivo. Por favor, inténtalo de nuevo.", + "tr": "Cihaz yetkilendirilemedi. Lütfen tekrar deneyin.", + "uk": "Не вдалося авторизувати пристрій. Будь ласка, спробуйте ще раз." + }, + "DEVICE$ERROR_OCCURRED": { + "en": "An error occurred while authorizing the device. Please try again.", + "ja": "デバイスの認証中にエラーが発生しました。もう一度お試しください。", + "zh-CN": "授权设备时发生错误。请重试。", + "zh-TW": "授權設備時發生錯誤。請重試。", + "ko-KR": "기기 인증 중 오류가 발생했습니다. 다시 시도해 주세요.", + "no": "En feil oppstod under autorisering av enheten. Vennligst prøv igjen.", + "ar": "حدث خطأ أثناء ترخيص الجهاز. يرجى المحاولة مرة أخرى.", + "de": "Bei der Geräteautorisierung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "fr": "Une erreur s'est produite lors de l'autorisation de l'appareil. Veuillez réessayer.", + "it": "Si è verificato un errore durante l'autorizzazione del dispositivo. Riprova.", + "pt": "Ocorreu um erro ao autorizar o dispositivo. Por favor, tente novamente.", + "es": "Ocurrió un error al autorizar el dispositivo. Por favor, inténtalo de nuevo.", + "tr": "Cihaz yetkilendirirken bir hata oluştu. Lütfen tekrar deneyin.", + "uk": "Під час авторизації пристрою сталася помилка. Будь ласка, спробуйте ще раз." + }, + "DEVICE$TRY_AGAIN": { + "en": "Try Again", + "ja": "再試行", + "zh-CN": "重试", + "zh-TW": "重試", + "ko-KR": "다시 시도", + "no": "Prøv igjen", + "ar": "حاول مرة أخرى", + "de": "Erneut versuchen", + "fr": "Réessayer", + "it": "Riprova", + "pt": "Tentar novamente", + "es": "Intentar de nuevo", + "tr": "Tekrar Dene", + "uk": "Спробувати ще раз" + }, + "DEVICE$PROCESSING": { + "en": "Processing device verification...", + "ja": "デバイス認証を処理中...", + "zh-CN": "正在处理设备验证...", + "zh-TW": "正在處理設備驗證...", + "ko-KR": "기기 인증 처리 중...", + "no": "Behandler enhetsverifisering...", + "ar": "جارٍ معالجة التحقق من الجهاز...", + "de": "Geräteverifizierung wird verarbeitet...", + "fr": "Traitement de la vérification de l'appareil...", + "it": "Elaborazione verifica dispositivo...", + "pt": "Processando verificação do dispositivo...", + "es": "Procesando verificación del dispositivo...", + "tr": "Cihaz doğrulaması işleniyor...", + "uk": "Обробка перевірки пристрою..." + }, + "DEVICE$AUTHORIZATION_REQUEST": { + "en": "Device Authorization Request", + "ja": "デバイス認証リクエスト", + "zh-CN": "设备授权请求", + "zh-TW": "設備授權請求", + "ko-KR": "기기 인증 요청", + "no": "Forespørsel om enhetsautorisasjon", + "ar": "طلب ترخيص الجهاز", + "de": "Geräteautorisierungsanfrage", + "fr": "Demande d'autorisation d'appareil", + "it": "Richiesta di autorizzazione dispositivo", + "pt": "Solicitação de autorização do dispositivo", + "es": "Solicitud de autorización del dispositivo", + "tr": "Cihaz Yetkilendirme Talebi", + "uk": "Запит на авторизацію пристрою" + }, + "DEVICE$CODE_LABEL": { + "en": "DEVICE CODE", + "ja": "デバイスコード", + "zh-CN": "设备代码", + "zh-TW": "設備代碼", + "ko-KR": "기기 코드", + "no": "ENHETSKODE", + "ar": "رمز الجهاز", + "de": "GERÄTECODE", + "fr": "CODE DE L'APPAREIL", + "it": "CODICE DISPOSITIVO", + "pt": "CÓDIGO DO DISPOSITIVO", + "es": "CÓDIGO DE DISPOSITIVO", + "tr": "CİHAZ KODU", + "uk": "КОД ПРИСТРОЮ" + }, + "DEVICE$SECURITY_NOTICE": { + "en": "Security Notice", + "ja": "セキュリティ通知", + "zh-CN": "安全提示", + "zh-TW": "安全提示", + "ko-KR": "보안 알림", + "no": "Sikkerhetsvarsel", + "ar": "إشعار أمني", + "de": "Sicherheitshinweis", + "fr": "Avis de sécurité", + "it": "Avviso di sicurezza", + "pt": "Aviso de segurança", + "es": "Aviso de seguridad", + "tr": "Güvenlik Bildirimi", + "uk": "Повідомлення про безпеку" + }, + "DEVICE$SECURITY_WARNING": { + "en": "Only authorize this device if you initiated this request from your CLI or application.", + "ja": "CLIまたはアプリケーションからこのリクエストを開始した場合のみ、このデバイスを認証してください。", + "zh-CN": "仅当您从 CLI 或应用程序发起此请求时,才授权此设备。", + "zh-TW": "僅當您從 CLI 或應用程式發起此請求時,才授權此設備。", + "ko-KR": "CLI 또는 애플리케이션에서 이 요청을 시작한 경우에만 이 기기를 인증하세요.", + "no": "Bare autoriser denne enheten hvis du startet denne forespørselen fra CLI eller applikasjonen din.", + "ar": "قم بترخيص هذا الجهاز فقط إذا كنت قد بدأت هذا الطلب من CLI أو التطبيق الخاص بك.", + "de": "Autorisieren Sie dieses Gerät nur, wenn Sie diese Anfrage von Ihrer CLI oder Anwendung aus gestartet haben.", + "fr": "N'autorisez cet appareil que si vous avez initié cette demande depuis votre CLI ou application.", + "it": "Autorizza questo dispositivo solo se hai avviato questa richiesta dalla tua CLI o applicazione.", + "pt": "Autorize este dispositivo apenas se você iniciou esta solicitação do seu CLI ou aplicativo.", + "es": "Solo autoriza este dispositivo si iniciaste esta solicitud desde tu CLI o aplicación.", + "tr": "Bu cihazı yalnızca bu isteği CLI veya uygulamanızdan başlattıysanız yetkilendirin.", + "uk": "Авторизуйте цей пристрій лише якщо ви ініціювали цей запит з вашого CLI або додатку." + }, + "DEVICE$CONFIRM_PROMPT": { + "en": "Do you want to authorize this device to access your OpenHands account?", + "ja": "このデバイスにOpenHandsアカウントへのアクセスを許可しますか?", + "zh-CN": "您想授权此设备访问您的 OpenHands 帐户吗?", + "zh-TW": "您想授權此設備訪問您的 OpenHands 帳戶嗎?", + "ko-KR": "이 기기가 OpenHands 계정에 액세스하도록 인증하시겠습니까?", + "no": "Vil du autorisere denne enheten til å få tilgang til din OpenHands-konto?", + "ar": "هل تريد ترخيص هذا الجهاز للوصول إلى حسابك في OpenHands؟", + "de": "Möchten Sie dieses Gerät autorisieren, um auf Ihr OpenHands-Konto zuzugreifen?", + "fr": "Voulez-vous autoriser cet appareil à accéder à votre compte OpenHands ?", + "it": "Vuoi autorizzare questo dispositivo ad accedere al tuo account OpenHands?", + "pt": "Deseja autorizar este dispositivo a acessar sua conta OpenHands?", + "es": "¿Deseas autorizar este dispositivo para acceder a tu cuenta de OpenHands?", + "tr": "Bu cihazın OpenHands hesabınıza erişmesine izin vermek istiyor musunuz?", + "uk": "Бажаєте авторизувати цей пристрій для доступу до вашого облікового запису OpenHands?" + }, + "DEVICE$CANCEL": { + "en": "Cancel", + "ja": "キャンセル", + "zh-CN": "取消", + "zh-TW": "取消", + "ko-KR": "취소", + "no": "Avbryt", + "ar": "إلغاء", + "de": "Abbrechen", + "fr": "Annuler", + "it": "Annulla", + "pt": "Cancelar", + "es": "Cancelar", + "tr": "İptal", + "uk": "Скасувати" + }, + "DEVICE$AUTHORIZE": { + "en": "Authorize Device", + "ja": "デバイスを認証", + "zh-CN": "授权设备", + "zh-TW": "授權設備", + "ko-KR": "기기 인증", + "no": "Autoriser enhet", + "ar": "ترخيص الجهاز", + "de": "Gerät autorisieren", + "fr": "Autoriser l'appareil", + "it": "Autorizza dispositivo", + "pt": "Autorizar dispositivo", + "es": "Autorizar dispositivo", + "tr": "Cihazı Yetkilendir", + "uk": "Авторизувати пристрій" + }, + "DEVICE$AUTHORIZATION_TITLE": { + "en": "Device Authorization", + "ja": "デバイス認証", + "zh-CN": "设备授权", + "zh-TW": "設備授權", + "ko-KR": "기기 인증", + "no": "Enhetsautorisasjon", + "ar": "ترخيص الجهاز", + "de": "Geräteautorisierung", + "fr": "Autorisation d'appareil", + "it": "Autorizzazione dispositivo", + "pt": "Autorização do dispositivo", + "es": "Autorización del dispositivo", + "tr": "Cihaz Yetkilendirme", + "uk": "Авторизація пристрою" + }, + "DEVICE$ENTER_CODE_PROMPT": { + "en": "Enter the code displayed on your device:", + "ja": "デバイスに表示されているコードを入力してください:", + "zh-CN": "输入设备上显示的代码:", + "zh-TW": "輸入設備上顯示的代碼:", + "ko-KR": "기기에 표시된 코드를 입력하세요:", + "no": "Skriv inn koden som vises på enheten din:", + "ar": "أدخل الرمز المعروض على جهازك:", + "de": "Geben Sie den auf Ihrem Gerät angezeigten Code ein:", + "fr": "Entrez le code affiché sur votre appareil :", + "it": "Inserisci il codice visualizzato sul tuo dispositivo:", + "pt": "Digite o código exibido no seu dispositivo:", + "es": "Ingresa el código mostrado en tu dispositivo:", + "tr": "Cihazınızda görüntülenen kodu girin:", + "uk": "Введіть код, відображений на вашому пристрої:" + }, + "DEVICE$CODE_INPUT_LABEL": { + "en": "Device Code:", + "ja": "デバイスコード:", + "zh-CN": "设备代码:", + "zh-TW": "設備代碼:", + "ko-KR": "기기 코드:", + "no": "Enhetskode:", + "ar": "رمز الجهاز:", + "de": "Gerätecode:", + "fr": "Code de l'appareil :", + "it": "Codice dispositivo:", + "pt": "Código do dispositivo:", + "es": "Código del dispositivo:", + "tr": "Cihaz Kodu:", + "uk": "Код пристрою:" + }, + "DEVICE$CODE_PLACEHOLDER": { + "en": "Enter your device code", + "ja": "デバイスコードを入力", + "zh-CN": "输入您的设备代码", + "zh-TW": "輸入您的設備代碼", + "ko-KR": "기기 코드를 입력하세요", + "no": "Skriv inn enhetskoden din", + "ar": "أدخل رمز جهازك", + "de": "Geben Sie Ihren Gerätecode ein", + "fr": "Entrez votre code d'appareil", + "it": "Inserisci il tuo codice dispositivo", + "pt": "Digite o código do seu dispositivo", + "es": "Ingresa tu código de dispositivo", + "tr": "Cihaz kodunuzu girin", + "uk": "Введіть код вашого пристрою" + }, + "DEVICE$CONTINUE": { + "en": "Continue", + "ja": "続行", + "zh-CN": "继续", + "zh-TW": "繼續", + "ko-KR": "계속", + "no": "Fortsett", + "ar": "متابعة", + "de": "Fortfahren", + "fr": "Continuer", + "it": "Continua", + "pt": "Continuar", + "es": "Continuar", + "tr": "Devam", + "uk": "Продовжити" + }, + "DEVICE$AUTH_REQUIRED": { + "en": "Authentication Required", + "ja": "認証が必要です", + "zh-CN": "需要身份验证", + "zh-TW": "需要身份驗證", + "ko-KR": "인증 필요", + "no": "Autentisering kreves", + "ar": "المصادقة مطلوبة", + "de": "Authentifizierung erforderlich", + "fr": "Authentification requise", + "it": "Autenticazione richiesta", + "pt": "Autenticação necessária", + "es": "Autenticación requerida", + "tr": "Kimlik Doğrulama Gerekli", + "uk": "Потрібна автентифікація" + }, + "DEVICE$SIGN_IN_PROMPT": { + "en": "Please sign in to authorize your device.", + "ja": "デバイスを認証するにはサインインしてください。", + "zh-CN": "请登录以授权您的设备。", + "zh-TW": "請登入以授權您的設備。", + "ko-KR": "기기를 인증하려면 로그인하세요.", + "no": "Vennligst logg inn for å autorisere enheten din.", + "ar": "يرجى تسجيل الدخول لترخيص جهازك.", + "de": "Bitte melden Sie sich an, um Ihr Gerät zu autorisieren.", + "fr": "Veuillez vous connecter pour autoriser votre appareil.", + "it": "Accedi per autorizzare il tuo dispositivo.", + "pt": "Por favor, faça login para autorizar seu dispositivo.", + "es": "Por favor, inicia sesión para autorizar tu dispositivo.", + "tr": "Cihazınızı yetkilendirmek için lütfen giriş yapın.", + "uk": "Будь ласка, увійдіть, щоб авторизувати свій пристрій." } } diff --git a/frontend/src/icons/check-circle-fill.svg b/frontend/src/icons/check-circle-fill.svg new file mode 100644 index 0000000000..c13fa064a5 --- /dev/null +++ b/frontend/src/icons/check-circle-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/routes/device-verify.tsx b/frontend/src/routes/device-verify.tsx index f306d660a5..aabc94e544 100644 --- a/frontend/src/routes/device-verify.tsx +++ b/frontend/src/routes/device-verify.tsx @@ -1,16 +1,22 @@ -/* eslint-disable i18next/no-literal-string */ import React, { useState } from "react"; import { useSearchParams } from "react-router"; +import { useTranslation } from "react-i18next"; import { useIsAuthed } from "#/hooks/query/use-is-authed"; +import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner"; +import { I18nKey } from "#/i18n/declaration"; +import { H1 } from "#/ui/typography"; +import { PROJ_USER_JOURNEY } from "#/utils/feature-flags"; export default function DeviceVerify() { + const { t } = useTranslation(); const [searchParams] = useSearchParams(); const { data: isAuthed, isLoading: isAuthLoading } = useIsAuthed(); const [verificationResult, setVerificationResult] = useState<{ success: boolean; - message: string; + messageKey: I18nKey; } | null>(null); const [isProcessing, setIsProcessing] = useState(false); + const showEnterpriseBanner = PROJ_USER_JOURNEY(); // Get user_code from URL parameters const userCode = searchParams.get("user_code"); @@ -33,21 +39,18 @@ export default function DeviceVerify() { // Show success message setVerificationResult({ success: true, - message: - "Device authorized successfully! You can now return to your CLI and close this window.", + messageKey: I18nKey.DEVICE$SUCCESS_MESSAGE, }); } else { - const errorText = await response.text(); setVerificationResult({ success: false, - message: errorText || "Failed to authorize device. Please try again.", + messageKey: I18nKey.DEVICE$ERROR_FAILED, }); } } catch (error) { setVerificationResult({ success: false, - message: - "An error occurred while authorizing the device. Please try again.", + messageKey: I18nKey.DEVICE$ERROR_OCCURRED, }); } finally { setIsProcessing(false); @@ -105,10 +108,12 @@ export default function DeviceVerify() { )}

- {verificationResult.success ? "Success!" : "Error"} + {verificationResult.success + ? t(I18nKey.DEVICE$SUCCESS_TITLE) + : t(I18nKey.DEVICE$ERROR_TITLE)}

- {verificationResult.message} + {t(verificationResult.messageKey)}

{!verificationResult.success && ( )}
@@ -133,7 +138,7 @@ export default function DeviceVerify() {

- Processing device verification... + {t(I18nKey.DEVICE$PROCESSING)}

@@ -144,63 +149,56 @@ export default function DeviceVerify() { // Show device authorization confirmation if user is authenticated and code is provided if (isAuthed && userCode) { return ( -
-
-

- Device Authorization Request -

-
-

Device Code:

-

- {userCode} +

+
+ {/* Device Authorization Card */} +
+

+ {t(I18nKey.DEVICE$AUTHORIZATION_REQUEST)} +

+
+

+ {t(I18nKey.DEVICE$CODE_LABEL)} +

+

+ {userCode} +

+
+
+

+ {t(I18nKey.DEVICE$SECURITY_NOTICE)} +

+

+ {t(I18nKey.DEVICE$SECURITY_WARNING)} +

+
+

+ {t(I18nKey.DEVICE$CONFIRM_PROMPT)}

-
-
-
- + -
-

- Security Notice -

-

- Only authorize this device if you initiated this request from - your CLI or application. -

-
+ {t(I18nKey.DEVICE$CANCEL)} + +
-

- Do you want to authorize this device to access your OpenHands - account? -

-
- - -
+ + {/* Enterprise Banner */} + {showEnterpriseBanner && }
); @@ -211,11 +209,11 @@ export default function DeviceVerify() { return (
-

- Device Authorization -

+

+ {t(I18nKey.DEVICE$AUTHORIZATION_TITLE)} +

- Enter the code displayed on your device: + {t(I18nKey.DEVICE$ENTER_CODE_PROMPT)}

@@ -223,7 +221,7 @@ export default function DeviceVerify() { htmlFor="user_code" className="block text-sm font-medium mb-2" > - Device Code: + {t(I18nKey.DEVICE$CODE_INPUT_LABEL)}
@@ -253,7 +251,7 @@ export default function DeviceVerify() {

- Processing device verification... + {t(I18nKey.DEVICE$PROCESSING)}

@@ -264,9 +262,9 @@ export default function DeviceVerify() { return (
-

Authentication Required

+

{t(I18nKey.DEVICE$AUTH_REQUIRED)}

- Please sign in to authorize your device. + {t(I18nKey.DEVICE$SIGN_IN_PROMPT)}

diff --git a/frontend/src/utils/feature-flags.ts b/frontend/src/utils/feature-flags.ts index fd5495d33b..ba691e76b7 100644 --- a/frontend/src/utils/feature-flags.ts +++ b/frontend/src/utils/feature-flags.ts @@ -20,3 +20,4 @@ export const ENABLE_TRAJECTORY_REPLAY = () => export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING"); export const ENABLE_SANDBOX_GROUPING = () => loadFeatureFlag("SANDBOX_GROUPING"); +export const PROJ_USER_JOURNEY = () => loadFeatureFlag("PROJ_USER_JOURNEY"); From 59dd1da7d6b9412ade7e3bd82758e3087478bea8 Mon Sep 17 00:00:00 2001 From: ankit kumar <65183107+ankit2235@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:51:05 +0530 Subject: [PATCH 36/92] fix: update deprecated libtmux API calls (#12596) Co-authored-by: ANKIT --- openhands/runtime/impl/local/local_runtime.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openhands/runtime/impl/local/local_runtime.py b/openhands/runtime/impl/local/local_runtime.py index 4f7804f705..ca55478c3e 100644 --- a/openhands/runtime/impl/local/local_runtime.py +++ b/openhands/runtime/impl/local/local_runtime.py @@ -115,10 +115,10 @@ def check_dependencies(code_repo_path: str, check_browser: bool) -> None: session = server.new_session(session_name='test-session') except Exception: raise ValueError('tmux is not properly installed or available on the path.') - pane = session.attached_pane + pane = session.active_pane pane.send_keys('echo "test"') pane_output = '\n'.join(pane.cmd('capture-pane', '-p').stdout) - session.kill_session() + session.kill() if 'test' not in pane_output: raise ValueError('libtmux is not properly installed. ' + ERROR_MESSAGE) From 8941111c4e77c4dcc4434d0b1cc7a27749775a80 Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Mon, 16 Mar 2026 17:34:27 -0600 Subject: [PATCH 37/92] refactor: use status instead of pod_status in RemoteSandboxService (#13436) Co-authored-by: openhands --- .../sandbox/remote_sandbox_service.py | 41 +++----- .../app_server/test_remote_sandbox_service.py | 93 +++++++------------ 2 files changed, 46 insertions(+), 88 deletions(-) diff --git a/openhands/app_server/sandbox/remote_sandbox_service.py b/openhands/app_server/sandbox/remote_sandbox_service.py index 2a95a3776c..6287f99d61 100644 --- a/openhands/app_server/sandbox/remote_sandbox_service.py +++ b/openhands/app_server/sandbox/remote_sandbox_service.py @@ -53,14 +53,6 @@ from openhands.sdk.utils.paging import page_iterator _logger = logging.getLogger(__name__) polling_task: asyncio.Task | None = None -POD_STATUS_MAPPING = { - 'ready': SandboxStatus.RUNNING, - 'pending': SandboxStatus.STARTING, - 'running': SandboxStatus.STARTING, - 'failed': SandboxStatus.ERROR, - 'unknown': SandboxStatus.ERROR, - 'crashloopbackoff': SandboxStatus.ERROR, -} STATUS_MAPPING = { 'running': SandboxStatus.RUNNING, 'paused': SandboxStatus.PAUSED, @@ -188,28 +180,22 @@ class RemoteSandboxService(SandboxService): def _get_sandbox_status_from_runtime( self, runtime: dict[str, Any] | None ) -> SandboxStatus: - """Derive a SandboxStatus from the runtime info. The legacy logic for getting - the status of a runtime is inconsistent. It is divided between a "status" which - cannot be trusted (It sometimes returns "running" for cases when the pod is - still starting) and a "pod_status" which is not returned for list - operations.""" + """Derive a SandboxStatus from the runtime info. + + The status field is now the source of truth for sandbox status. It accounts + for both pod readiness and ingress availability, making it more reliable than + pod_status which only reflected pod state. + """ if not runtime: return SandboxStatus.MISSING - status = None - pod_status = (runtime.get('pod_status') or '').lower() - if pod_status: - status = POD_STATUS_MAPPING.get(pod_status, None) + runtime_status = runtime.get('status') + if runtime_status: + status = STATUS_MAPPING.get(runtime_status.lower(), None) + if status is not None: + return status - # If we failed to get the status from the pod status, fall back to status - if status is None: - runtime_status = runtime.get('status') - if runtime_status: - status = STATUS_MAPPING.get(runtime_status.lower(), None) - - if status is None: - return SandboxStatus.MISSING - return status + return SandboxStatus.MISSING async def _secure_select(self): query = select(StoredRemoteSandbox) @@ -514,9 +500,6 @@ class RemoteSandboxService(SandboxService): session_api_key ) - # Hack - result doesn't contain this - runtime_data['pod_status'] = 'pending' - # Log runtime assignment for observability runtime_id = runtime_data.get('runtime_id', 'unknown') _logger.info(f'Started sandbox {sandbox_id} with runtime_id={runtime_id}') diff --git a/tests/unit/app_server/test_remote_sandbox_service.py b/tests/unit/app_server/test_remote_sandbox_service.py index 7e8a2178dd..a7547092ef 100644 --- a/tests/unit/app_server/test_remote_sandbox_service.py +++ b/tests/unit/app_server/test_remote_sandbox_service.py @@ -22,7 +22,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from openhands.app_server.errors import SandboxError from openhands.app_server.sandbox.remote_sandbox_service import ( ALLOW_CORS_ORIGINS_VARIABLE, - POD_STATUS_MAPPING, STATUS_MAPPING, WEBHOOK_CALLBACK_VARIABLE, RemoteSandboxService, @@ -98,7 +97,6 @@ def remote_sandbox_service( def create_runtime_data( session_id: str = 'test-sandbox-123', status: str = 'running', - pod_status: str = 'ready', url: str = 'https://sandbox.example.com', session_api_key: str = 'test-session-key', runtime_id: str = 'runtime-456', @@ -107,7 +105,6 @@ def create_runtime_data( return { 'session_id': session_id, 'status': status, - 'pod_status': pod_status, 'url': url, 'session_api_key': session_api_key, 'runtime_id': runtime_id, @@ -188,24 +185,11 @@ class TestStatusMapping: """Test cases for status mapping functionality.""" @pytest.mark.asyncio - async def test_get_sandbox_status_from_runtime_with_pod_status( + async def test_get_sandbox_status_from_runtime_with_status( self, remote_sandbox_service ): - """Test status mapping using pod_status.""" - runtime_data = create_runtime_data(pod_status='ready') - - status = remote_sandbox_service._get_sandbox_status_from_runtime(runtime_data) - - assert status == SandboxStatus.RUNNING - - @pytest.mark.asyncio - async def test_get_sandbox_status_from_runtime_fallback_to_status( - self, remote_sandbox_service - ): - """Test status mapping fallback to status field.""" - runtime_data = create_runtime_data( - pod_status='unknown_pod_status', status='running' - ) + """Test status mapping using status field.""" + runtime_data = create_runtime_data(status='running') status = remote_sandbox_service._get_sandbox_status_from_runtime(runtime_data) @@ -225,32 +209,22 @@ class TestStatusMapping: self, remote_sandbox_service ): """Test status mapping with unknown status values.""" - runtime_data = create_runtime_data( - pod_status='unknown_pod', status='unknown_status' - ) + runtime_data = create_runtime_data(status='unknown_status') status = remote_sandbox_service._get_sandbox_status_from_runtime(runtime_data) assert status == SandboxStatus.MISSING @pytest.mark.asyncio - async def test_pod_status_mapping_coverage(self, remote_sandbox_service): - """Test all pod status mappings are handled correctly.""" - test_cases = [ - ('ready', SandboxStatus.RUNNING), - ('pending', SandboxStatus.STARTING), - ('running', SandboxStatus.STARTING), - ('failed', SandboxStatus.ERROR), - ('unknown', SandboxStatus.ERROR), - ('crashloopbackoff', SandboxStatus.ERROR), - ] + async def test_get_sandbox_status_from_runtime_empty_status( + self, remote_sandbox_service + ): + """Test status mapping with empty status field.""" + runtime_data = create_runtime_data(status='') - for pod_status, expected_status in test_cases: - runtime_data = create_runtime_data(pod_status=pod_status) - status = remote_sandbox_service._get_sandbox_status_from_runtime( - runtime_data - ) - assert status == expected_status, f'Failed for pod_status: {pod_status}' + status = remote_sandbox_service._get_sandbox_status_from_runtime(runtime_data) + + assert status == SandboxStatus.MISSING @pytest.mark.asyncio async def test_status_mapping_coverage(self, remote_sandbox_service): @@ -264,8 +238,24 @@ class TestStatusMapping: ] for status, expected_status in test_cases: - # Use empty pod_status to force fallback to status field - runtime_data = create_runtime_data(pod_status='', status=status) + runtime_data = create_runtime_data(status=status) + result = remote_sandbox_service._get_sandbox_status_from_runtime( + runtime_data + ) + assert result == expected_status, f'Failed for status: {status}' + + @pytest.mark.asyncio + async def test_status_mapping_case_insensitive(self, remote_sandbox_service): + """Test that status mapping is case-insensitive.""" + test_cases = [ + ('RUNNING', SandboxStatus.RUNNING), + ('Running', SandboxStatus.RUNNING), + ('PAUSED', SandboxStatus.PAUSED), + ('Starting', SandboxStatus.STARTING), + ] + + for status, expected_status in test_cases: + runtime_data = create_runtime_data(status=status) result = remote_sandbox_service._get_sandbox_status_from_runtime( runtime_data ) @@ -336,7 +326,7 @@ class TestSandboxInfoConversion: """Test conversion to SandboxInfo with running runtime.""" # Setup stored_sandbox = create_stored_sandbox() - runtime_data = create_runtime_data(status='running', pod_status='ready') + runtime_data = create_runtime_data(status='running') # Execute sandbox_info = remote_sandbox_service._to_sandbox_info( @@ -363,7 +353,7 @@ class TestSandboxInfoConversion: """Test conversion to SandboxInfo with starting runtime.""" # Setup stored_sandbox = create_stored_sandbox() - runtime_data = create_runtime_data(status='running', pod_status='pending') + runtime_data = create_runtime_data(status='starting') # Execute sandbox_info = remote_sandbox_service._to_sandbox_info( @@ -400,7 +390,7 @@ class TestSandboxLifecycle: """Test successful sandbox start.""" # Setup mock_response = MagicMock() - mock_response.json.return_value = create_runtime_data() + mock_response.json.return_value = create_runtime_data(status='running') remote_sandbox_service.httpx_client.request.return_value = mock_response remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[]) @@ -414,9 +404,7 @@ class TestSandboxLifecycle: # Verify assert sandbox_info.id == 'test-sandbox-123' - assert ( - sandbox_info.status == SandboxStatus.STARTING - ) # pod_status is 'pending' by default + assert sandbox_info.status == SandboxStatus.RUNNING remote_sandbox_service.pause_old_sandboxes.assert_called_once_with( 9 ) # max_num_sandboxes - 1 @@ -1267,19 +1255,6 @@ class TestUtilityFunctions: class TestConstants: """Test cases for constants and mappings.""" - def test_pod_status_mapping_completeness(self): - """Test that POD_STATUS_MAPPING covers expected statuses.""" - expected_statuses = [ - 'ready', - 'pending', - 'running', - 'failed', - 'unknown', - 'crashloopbackoff', - ] - for status in expected_statuses: - assert status in POD_STATUS_MAPPING, f'Missing pod status: {status}' - def test_status_mapping_completeness(self): """Test that STATUS_MAPPING covers expected statuses.""" expected_statuses = ['running', 'paused', 'stopped', 'starting', 'error'] From 75c823c4862eda39180d214a3ab359f63add3151 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Tue, 17 Mar 2026 12:54:57 +0000 Subject: [PATCH 38/92] feat: expose_secrets param on /users/me + sandbox-scoped secrets API (#13383) Co-authored-by: openhands --- AGENTS.md | 27 + enterprise/integrations/resolver_context.py | 4 +- .../live_status_app_conversation_service.py | 9 +- .../app_server/sandbox/sandbox_models.py | 17 + .../app_server/sandbox/sandbox_router.py | 121 ++- openhands/app_server/sandbox/session_auth.py | 66 ++ .../app_server/user/auth_user_context.py | 27 +- .../app_server/user/specifiy_user_context.py | 4 +- openhands/app_server/user/user_context.py | 12 +- openhands/app_server/user/user_router.py | 51 +- .../app_server/test_sandbox_secrets_router.py | 787 ++++++++++++++++++ 11 files changed, 1110 insertions(+), 15 deletions(-) create mode 100644 openhands/app_server/sandbox/session_auth.py create mode 100644 tests/unit/app_server/test_sandbox_secrets_router.py diff --git a/AGENTS.md b/AGENTS.md index 878a26e884..5bcf99cf8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -342,3 +342,30 @@ To add a new LLM model to OpenHands, you need to update multiple files across bo - Models appear in CLI provider selection based on the verified arrays - The `organize_models_and_providers` function groups models by provider - Default model selection prioritizes verified models for each provider + +### Sandbox Settings API (SDK Credential Inheritance) + +The sandbox settings API allows SDK-created conversations to inherit the user's SaaS credentials +(LLM config, secrets) securely via `LookupSecret`. Raw secret values only flow SaaS→sandbox, +never through the SDK client. + +#### User Credentials with Exposed Secrets (in `openhands/app_server/user/user_router.py`): +- `GET /api/v1/users/me?expose_secrets=true` → Full user settings with unmasked secrets (e.g., `llm_api_key`) +- `GET /api/v1/users/me` → Full user settings (secrets masked, Bearer only) + +Auth requirements for `expose_secrets=true`: +- Bearer token (proves user identity via `OPENHANDS_API_KEY`) +- `X-Session-API-Key` header (proves caller has an active sandbox owned by the authenticated user) + +Called by `workspace.get_llm()` in the SDK to retrieve LLM config with the API key. + +#### Sandbox-Scoped Secrets Endpoints (in `openhands/app_server/sandbox/sandbox_router.py`): +- `GET /sandboxes/{id}/settings/secrets` → list secret names (no values) +- `GET /sandboxes/{id}/settings/secrets/{name}` → raw secret value (called FROM sandbox) + +#### Auth: `X-Session-API-Key` header, validated via `SandboxService.get_sandbox_by_session_api_key()` + +#### Related SDK code (in `software-agent-sdk` repo): +- `openhands/sdk/llm/llm.py`: `LLM.api_key` accepts `SecretSource` (including `LookupSecret`) +- `openhands/workspace/cloud/workspace.py`: `get_llm()` and `get_secrets()` return LookupSecret-backed objects +- Tests: `tests/sdk/llm/test_llm_secret_source_api_key.py`, `tests/workspace/test_cloud_workspace_sdk_settings.py` diff --git a/enterprise/integrations/resolver_context.py b/enterprise/integrations/resolver_context.py index 441c132f2c..30e558a111 100644 --- a/enterprise/integrations/resolver_context.py +++ b/enterprise/integrations/resolver_context.py @@ -60,7 +60,9 @@ class ResolverUserContext(UserContext): return provider_token.token.get_secret_value() return None - async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None: + async def get_provider_tokens( + self, as_env_vars: bool = False + ) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None: return await self.saas_user_auth.get_provider_tokens() async def get_secrets(self) -> dict[str, SecretSource]: diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 902cde7771..703899ec83 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -7,7 +7,7 @@ import zipfile from collections import defaultdict from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any, AsyncGenerator, Sequence +from typing import Any, AsyncGenerator, Sequence, cast from uuid import UUID, uuid4 import httpx @@ -84,7 +84,7 @@ from openhands.app_server.utils.llm_metadata import ( get_llm_metadata, should_set_litellm_extra_body, ) -from openhands.integrations.provider import ProviderType +from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType from openhands.integrations.service_types import SuggestedTask from openhands.sdk import Agent, AgentContext, LocalWorkspace from openhands.sdk.hooks import HookConfig @@ -837,7 +837,10 @@ class LiveStatusAppConversationService(AppConversationServiceBase): secrets = await self.user_context.get_secrets() # Get all provider tokens from user authentication - provider_tokens = await self.user_context.get_provider_tokens() + provider_tokens = cast( + PROVIDER_TOKEN_TYPE | None, + await self.user_context.get_provider_tokens(), + ) if not provider_tokens: return secrets diff --git a/openhands/app_server/sandbox/sandbox_models.py b/openhands/app_server/sandbox/sandbox_models.py index 948e5c17c2..c64d188b34 100644 --- a/openhands/app_server/sandbox/sandbox_models.py +++ b/openhands/app_server/sandbox/sandbox_models.py @@ -59,3 +59,20 @@ class SandboxInfo(BaseModel): class SandboxPage(BaseModel): items: list[SandboxInfo] next_page_id: str | None = None + + +class SecretNameItem(BaseModel): + """A secret's name and optional description (value NOT included).""" + + name: str = Field(description='The secret name/key') + description: str | None = Field( + default=None, description='Optional description of the secret' + ) + + +class SecretNamesResponse(BaseModel): + """Response listing available secret names (no raw values).""" + + secrets: list[SecretNameItem] = Field( + default_factory=list, description='Available secrets' + ) diff --git a/openhands/app_server/sandbox/sandbox_router.py b/openhands/app_server/sandbox/sandbox_router.py index 79a3ef6b82..7b2575c3e7 100644 --- a/openhands/app_server/sandbox/sandbox_router.py +++ b/openhands/app_server/sandbox/sandbox_router.py @@ -1,16 +1,30 @@ """Runtime Containers router for OpenHands App Server.""" -from typing import Annotated +import logging +from typing import Annotated, cast -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from fastapi.security import APIKeyHeader from openhands.agent_server.models import Success from openhands.app_server.config import depends_sandbox_service -from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxPage +from openhands.app_server.sandbox.sandbox_models import ( + SandboxInfo, + SandboxPage, + SecretNameItem, + SecretNamesResponse, +) from openhands.app_server.sandbox.sandbox_service import ( SandboxService, ) +from openhands.app_server.sandbox.session_auth import validate_session_key +from openhands.app_server.user.auth_user_context import AuthUserContext from openhands.server.dependencies import get_dependencies +from openhands.server.user_auth.user_auth import ( + get_for_user as get_user_auth_for_user, +) + +_logger = logging.getLogger(__name__) # We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint # is protected. The actual protection is provided by SetAuthCookieMiddleware @@ -94,3 +108,104 @@ async def delete_sandbox( if not exists: raise HTTPException(status.HTTP_404_NOT_FOUND) return Success() + + +# --------------------------------------------------------------------------- +# Sandbox-scoped secrets (authenticated via X-Session-API-Key) +# --------------------------------------------------------------------------- + + +async def _valid_sandbox_from_session_key( + request: Request, + sandbox_id: str, + session_api_key: str = Depends( + APIKeyHeader(name='X-Session-API-Key', auto_error=False) + ), +) -> SandboxInfo: + """Authenticate via ``X-Session-API-Key`` and verify sandbox ownership.""" + sandbox_info = await validate_session_key(session_api_key) + + if sandbox_info.id != sandbox_id: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail='Session API key does not match sandbox', + ) + + return sandbox_info + + +async def _get_user_context(sandbox_info: SandboxInfo) -> AuthUserContext: + """Build an ``AuthUserContext`` for the sandbox owner.""" + if not sandbox_info.created_by_user_id: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail='Sandbox has no associated user', + ) + user_auth = await get_user_auth_for_user(sandbox_info.created_by_user_id) + return AuthUserContext(user_auth=user_auth) + + +@router.get('/{sandbox_id}/settings/secrets') +async def list_secret_names( + sandbox_info: SandboxInfo = Depends(_valid_sandbox_from_session_key), +) -> SecretNamesResponse: + """List available secret names (no raw values). + + Includes both custom secrets and provider tokens (e.g. github_token). + """ + user_context = await _get_user_context(sandbox_info) + + items: list[SecretNameItem] = [] + + # Custom secrets + secret_sources = await user_context.get_secrets() + for name, source in secret_sources.items(): + items.append(SecretNameItem(name=name, description=source.description)) + + # Provider tokens (github_token, gitlab_token, etc.) + provider_env_vars = cast( + dict[str, str] | None, + await user_context.get_provider_tokens(as_env_vars=True), + ) + if provider_env_vars: + for env_key in provider_env_vars: + items.append( + SecretNameItem(name=env_key, description=f'{env_key} provider token') + ) + + return SecretNamesResponse(secrets=items) + + +@router.get('/{sandbox_id}/settings/secrets/{secret_name}') +async def get_secret_value( + secret_name: str, + sandbox_info: SandboxInfo = Depends(_valid_sandbox_from_session_key), +) -> Response: + """Return a single secret value as plain text. + + Called by ``LookupSecret`` inside the sandbox. Checks custom secrets + first, then falls back to provider tokens — always resolving the + latest token at call time. + """ + user_context = await _get_user_context(sandbox_info) + + # Check custom secrets first + secret_sources = await user_context.get_secrets() + source = secret_sources.get(secret_name) + if source is not None: + value = source.get_value() + if value is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Secret has no value') + return Response(content=value, media_type='text/plain') + + # Fall back to provider tokens (resolved fresh per request) + provider_env_vars = cast( + dict[str, str] | None, + await user_context.get_provider_tokens(as_env_vars=True), + ) + if provider_env_vars: + token_value = provider_env_vars.get(secret_name) + if token_value is not None: + return Response(content=token_value, media_type='text/plain') + + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Secret not found') diff --git a/openhands/app_server/sandbox/session_auth.py b/openhands/app_server/sandbox/session_auth.py new file mode 100644 index 0000000000..5ae7f418b6 --- /dev/null +++ b/openhands/app_server/sandbox/session_auth.py @@ -0,0 +1,66 @@ +"""Shared session-key authentication for sandbox-scoped endpoints. + +Both the sandbox router and the user router need to validate +``X-Session-API-Key`` headers. This module centralises that logic so +it lives in exactly one place. + +The ``InjectorState`` + ``ADMIN`` pattern used here is established in +``webhook_router.py`` — the sandbox service requires an admin context to +look up sandboxes across all users by session key, but the session key +itself acts as the proof of access. +""" + +import logging + +from fastapi import HTTPException, status + +from openhands.app_server.config import get_global_config, get_sandbox_service +from openhands.app_server.sandbox.sandbox_models import SandboxInfo +from openhands.app_server.services.injector import InjectorState +from openhands.app_server.user.specifiy_user_context import ADMIN, USER_CONTEXT_ATTR +from openhands.server.types import AppMode + +_logger = logging.getLogger(__name__) + + +async def validate_session_key(session_api_key: str | None) -> SandboxInfo: + """Validate an ``X-Session-API-Key`` and return the associated sandbox. + + Raises: + HTTPException(401): if the key is missing or does not map to a sandbox. + HTTPException(401): in SAAS mode if the sandbox has no owning user. + """ + if not session_api_key: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail='X-Session-API-Key header is required', + ) + + # The sandbox service is scoped to users. To look up a sandbox by session + # key (which could belong to *any* user) we need an admin context. This + # is the same pattern used in webhook_router.valid_sandbox(). + state = InjectorState() + setattr(state, USER_CONTEXT_ATTR, ADMIN) + + async with get_sandbox_service(state) as sandbox_service: + sandbox_info = await sandbox_service.get_sandbox_by_session_api_key( + session_api_key + ) + + if sandbox_info is None: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key' + ) + + if not sandbox_info.created_by_user_id: + if get_global_config().app_mode == AppMode.SAAS: + _logger.error( + 'Sandbox had no user specified', + extra={'sandbox_id': sandbox_info.id}, + ) + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail='Sandbox had no user specified', + ) + + return sandbox_info diff --git a/openhands/app_server/user/auth_user_context.py b/openhands/app_server/user/auth_user_context.py index c6f7df07de..663ab0bbfd 100644 --- a/openhands/app_server/user/auth_user_context.py +++ b/openhands/app_server/user/auth_user_context.py @@ -48,8 +48,27 @@ class AuthUserContext(UserContext): self._user_info = user_info return user_info - async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None: - return await self.user_auth.get_provider_tokens() + async def get_provider_tokens( + self, as_env_vars: bool = False + ) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None: + """Return provider tokens. + + Args: + as_env_vars: When True, return a ``dict[str, str]`` mapping env + var names (e.g. ``github_token``) to plain-text token values, + resolving the latest value at call time. When False (default), + return the raw ``dict[ProviderType, ProviderToken]``. + """ + provider_tokens = await self.user_auth.get_provider_tokens() + if not as_env_vars: + return provider_tokens + results: dict[str, str] = {} + if provider_tokens: + for provider_type, provider_token in provider_tokens.items(): + if provider_token.token: + env_key = ProviderHandler.get_provider_env_key(provider_type) + results[env_key] = provider_token.token.get_secret_value() + return results async def get_provider_handler(self): provider_handler = self._provider_handler @@ -79,9 +98,9 @@ class AuthUserContext(UserContext): return token async def get_secrets(self) -> dict[str, SecretSource]: - results = {} + results: dict[str, SecretSource] = {} - # Include custom secrets... + # Include custom secrets secrets = await self.user_auth.get_secrets() if secrets: for name, custom_secret in secrets.custom_secrets.items(): diff --git a/openhands/app_server/user/specifiy_user_context.py b/openhands/app_server/user/specifiy_user_context.py index 0127d13129..087aac9e7f 100644 --- a/openhands/app_server/user/specifiy_user_context.py +++ b/openhands/app_server/user/specifiy_user_context.py @@ -26,7 +26,9 @@ class SpecifyUserContext(UserContext): ) -> str: raise NotImplementedError() - async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None: + async def get_provider_tokens( + self, as_env_vars: bool = False + ) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None: raise NotImplementedError() async def get_latest_token(self, provider_type: ProviderType) -> str | None: diff --git a/openhands/app_server/user/user_context.py b/openhands/app_server/user/user_context.py index 36aee854ae..625b4cd43e 100644 --- a/openhands/app_server/user/user_context.py +++ b/openhands/app_server/user/user_context.py @@ -35,8 +35,16 @@ class UserContext(ABC): """ @abstractmethod - async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None: - """Get the latest tokens for all provider types""" + async def get_provider_tokens( + self, as_env_vars: bool = False + ) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None: + """Get the latest tokens for all provider types. + + Args: + as_env_vars: When True, return a ``dict[str, str]`` mapping env + var names (e.g. ``github_token``) to plain-text token values. + When False (default), return the raw provider token mapping. + """ @abstractmethod async def get_latest_token(self, provider_type: ProviderType) -> str | None: diff --git a/openhands/app_server/user/user_router.py b/openhands/app_server/user/user_router.py index 2926c8495d..3cee936bde 100644 --- a/openhands/app_server/user/user_router.py +++ b/openhands/app_server/user/user_router.py @@ -1,12 +1,18 @@ """User router for OpenHands App Server. For the moment, this simply implements the /me endpoint.""" -from fastapi import APIRouter, HTTPException, status +import logging + +from fastapi import APIRouter, Header, HTTPException, Query, status +from fastapi.responses import JSONResponse from openhands.app_server.config import depends_user_context +from openhands.app_server.sandbox.session_auth import validate_session_key from openhands.app_server.user.user_context import UserContext from openhands.app_server.user.user_models import UserInfo from openhands.server.dependencies import get_dependencies +_logger = logging.getLogger(__name__) + # We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint # is protected. The actual protection is provided by SetAuthCookieMiddleware router = APIRouter(prefix='/users', tags=['User'], dependencies=get_dependencies()) @@ -18,9 +24,52 @@ user_dependency = depends_user_context() @router.get('/me') async def get_current_user( user_context: UserContext = user_dependency, + expose_secrets: bool = Query( + default=False, + description='If true, return unmasked secret values (e.g. llm_api_key). ' + 'Requires a valid X-Session-API-Key header for an active sandbox ' + 'owned by the authenticated user.', + ), + x_session_api_key: str | None = Header(default=None), ) -> UserInfo: """Get the current authenticated user.""" user = await user_context.get_user_info() if user is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Not authenticated') + if expose_secrets: + await _validate_session_key_ownership(user_context, x_session_api_key) + return JSONResponse( # type: ignore[return-value] + content=user.model_dump(mode='json', context={'expose_secrets': True}) + ) return user + + +async def _validate_session_key_ownership( + user_context: UserContext, + session_api_key: str | None, +) -> None: + """Verify the session key belongs to a sandbox owned by the caller. + + Raises ``HTTPException`` if the key is missing, invalid, or belongs + to a sandbox owned by a different user. + """ + sandbox_info = await validate_session_key(session_api_key) + + # Verify the sandbox is owned by the authenticated user. + caller_id = await user_context.get_user_id() + if not caller_id: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail='Cannot determine authenticated user', + ) + + if sandbox_info.created_by_user_id != caller_id: + _logger.warning( + 'Session key user mismatch: sandbox owner=%s, caller=%s', + sandbox_info.created_by_user_id, + caller_id, + ) + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail='Session API key does not belong to the authenticated user', + ) diff --git a/tests/unit/app_server/test_sandbox_secrets_router.py b/tests/unit/app_server/test_sandbox_secrets_router.py new file mode 100644 index 0000000000..5976098bc1 --- /dev/null +++ b/tests/unit/app_server/test_sandbox_secrets_router.py @@ -0,0 +1,787 @@ +"""Unit + integration tests for the sandbox settings endpoints and /users/me expose_secrets. + +Tests: +- GET /api/v1/users/me?expose_secrets=true +- GET /api/v1/sandboxes/{sandbox_id}/settings/secrets +- GET /api/v1/sandboxes/{sandbox_id}/settings/secrets/{secret_name} +- Shared session_auth.validate_session_key() +- Integration tests exercising the real auth validation stack via HTTP +""" + +import contextlib +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pydantic import SecretStr + +from openhands.app_server.sandbox.sandbox_models import ( + SandboxInfo, + SandboxStatus, + SecretNamesResponse, +) +from openhands.app_server.sandbox.sandbox_router import ( + get_secret_value, + list_secret_names, +) +from openhands.app_server.sandbox.session_auth import validate_session_key +from openhands.app_server.user.auth_user_context import AuthUserContext +from openhands.app_server.user.user_models import UserInfo +from openhands.app_server.user.user_router import ( + _validate_session_key_ownership, + get_current_user, +) +from openhands.integrations.provider import ProviderHandler, ProviderToken +from openhands.integrations.service_types import ProviderType +from openhands.sdk.secret import StaticSecret + +SANDBOX_ID = 'sb-test-123' +USER_ID = 'test-user-id' + + +def _make_sandbox_info( + sandbox_id: str = SANDBOX_ID, + user_id: str | None = USER_ID, +) -> SandboxInfo: + return SandboxInfo( + id=sandbox_id, + created_by_user_id=user_id, + sandbox_spec_id='test-spec', + status=SandboxStatus.RUNNING, + session_api_key='session-key', + ) + + +def _patch_sandbox_service(return_sandbox: SandboxInfo | None): + """Patch ``get_sandbox_service`` in ``session_auth`` to return a mock service.""" + mock_sandbox_service = AsyncMock() + mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock( + return_value=return_sandbox + ) + ctx = patch( + 'openhands.app_server.sandbox.session_auth.get_sandbox_service', + ) + return ctx, mock_sandbox_service + + +def _create_sandbox_service_context_manager(sandbox_service): + """Create an async context manager that yields the given sandbox service.""" + + @contextlib.asynccontextmanager + async def _context_manager(state, request=None): + yield sandbox_service + + return _context_manager + + +# --------------------------------------------------------------------------- +# validate_session_key (shared utility) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestValidateSessionKey: + """Tests for the shared session_auth.validate_session_key utility.""" + + async def test_rejects_missing_key(self): + """Missing session key raises 401.""" + with pytest.raises(HTTPException) as exc_info: + await validate_session_key(None) + assert exc_info.value.status_code == 401 + assert 'X-Session-API-Key' in exc_info.value.detail + + async def test_rejects_empty_string_key(self): + """Empty string session key raises 401.""" + with pytest.raises(HTTPException) as exc_info: + await validate_session_key('') + assert exc_info.value.status_code == 401 + + async def test_rejects_invalid_key(self): + """Session key that maps to no sandbox raises 401.""" + ctx, mock_svc = _patch_sandbox_service(None) + with ctx as mock_get: + mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc) + mock_get.return_value.__aexit__ = AsyncMock(return_value=False) + + with pytest.raises(HTTPException) as exc_info: + await validate_session_key('bogus-key') + assert exc_info.value.status_code == 401 + assert 'Invalid session API key' in exc_info.value.detail + + async def test_accepts_valid_key(self): + """Valid session key returns sandbox info.""" + sandbox = _make_sandbox_info() + ctx, mock_svc = _patch_sandbox_service(sandbox) + with ctx as mock_get: + mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc) + mock_get.return_value.__aexit__ = AsyncMock(return_value=False) + + result = await validate_session_key('valid-key') + assert result.id == SANDBOX_ID + + async def test_rejects_sandbox_without_user_in_saas_mode(self): + """In SAAS mode, sandbox without created_by_user_id raises 401.""" + sandbox = _make_sandbox_info(user_id=None) + ctx, mock_svc = _patch_sandbox_service(sandbox) + with ( + ctx as mock_get, + patch( + 'openhands.app_server.sandbox.session_auth.get_global_config' + ) as mock_cfg, + ): + mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc) + mock_get.return_value.__aexit__ = AsyncMock(return_value=False) + + from openhands.server.types import AppMode + + mock_cfg.return_value.app_mode = AppMode.SAAS + + with pytest.raises(HTTPException) as exc_info: + await validate_session_key('valid-key') + assert exc_info.value.status_code == 401 + assert 'no user' in exc_info.value.detail + + +# --------------------------------------------------------------------------- +# GET /users/me?expose_secrets=true +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestGetCurrentUserExposeSecrets: + """Test suite for GET /users/me?expose_secrets=true.""" + + async def test_expose_secrets_returns_raw_api_key(self): + """With valid session key, expose_secrets=true returns unmasked llm_api_key.""" + user_info = UserInfo( + id=USER_ID, + llm_model='anthropic/claude-sonnet-4-20250514', + llm_api_key=SecretStr('sk-test-key-123'), + llm_base_url='https://litellm.example.com', + ) + mock_context = AsyncMock() + mock_context.get_user_info = AsyncMock(return_value=user_info) + mock_context.get_user_id = AsyncMock(return_value=USER_ID) + + with patch( + 'openhands.app_server.user.user_router._validate_session_key_ownership' + ) as mock_validate: + mock_validate.return_value = None + result = await get_current_user( + user_context=mock_context, + expose_secrets=True, + x_session_api_key='valid-key', + ) + + # JSONResponse — parse the body + import json + + body = json.loads(result.body) + assert body['llm_model'] == 'anthropic/claude-sonnet-4-20250514' + assert body['llm_api_key'] == 'sk-test-key-123' + assert body['llm_base_url'] == 'https://litellm.example.com' + + async def test_expose_secrets_rejects_missing_session_key(self): + """expose_secrets=true without X-Session-API-Key is rejected.""" + mock_context = AsyncMock() + + with pytest.raises(HTTPException) as exc_info: + await _validate_session_key_ownership(mock_context, session_api_key=None) + assert exc_info.value.status_code == 401 + assert 'X-Session-API-Key' in exc_info.value.detail + + async def test_expose_secrets_rejects_wrong_user(self): + """expose_secrets=true with session key from different user is rejected.""" + mock_context = AsyncMock() + mock_context.get_user_id = AsyncMock(return_value='user-A') + + other_user_sandbox = _make_sandbox_info(user_id='user-B') + + ctx, mock_svc = _patch_sandbox_service(other_user_sandbox) + with ctx as mock_get, pytest.raises(HTTPException) as exc_info: + mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc) + mock_get.return_value.__aexit__ = AsyncMock(return_value=False) + + await _validate_session_key_ownership( + mock_context, session_api_key='stolen-key' + ) + + assert exc_info.value.status_code == 403 + + async def test_expose_secrets_rejects_unknown_caller(self): + """If caller_id cannot be determined, reject with 401.""" + mock_context = AsyncMock() + mock_context.get_user_id = AsyncMock(return_value=None) + + sandbox = _make_sandbox_info(user_id='user-B') + + ctx, mock_svc = _patch_sandbox_service(sandbox) + with ctx as mock_get, pytest.raises(HTTPException) as exc_info: + mock_get.return_value.__aenter__ = AsyncMock(return_value=mock_svc) + mock_get.return_value.__aexit__ = AsyncMock(return_value=False) + + await _validate_session_key_ownership( + mock_context, session_api_key='some-key' + ) + + assert exc_info.value.status_code == 401 + assert 'Cannot determine authenticated user' in exc_info.value.detail + + async def test_default_masks_api_key(self): + """Without expose_secrets, llm_api_key is masked (no session key needed).""" + user_info = UserInfo( + id=USER_ID, + llm_api_key=SecretStr('sk-test-key-123'), + ) + mock_context = AsyncMock() + mock_context.get_user_info = AsyncMock(return_value=user_info) + + result = await get_current_user( + user_context=mock_context, expose_secrets=False, x_session_api_key=None + ) + + # Returns UserInfo directly (FastAPI will serialize with masking) + assert isinstance(result, UserInfo) + assert result.llm_api_key is not None + # The raw value is still in the object, but serialization masks it + dumped = result.model_dump(mode='json') + assert dumped['llm_api_key'] == '**********' + + +# --------------------------------------------------------------------------- +# GET /sandboxes/{sandbox_id}/settings/secrets +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestListSecretNames: + """Test suite for GET /sandboxes/{sandbox_id}/settings/secrets.""" + + async def test_returns_secret_names_without_values(self): + """Response contains names and descriptions, NOT raw values.""" + secrets = { + 'GITHUB_TOKEN': StaticSecret( + value=SecretStr('ghp_test123'), + description='GitHub personal access token', + ), + 'MY_API_KEY': StaticSecret( + value=SecretStr('my-api-key-value'), + description='Custom API key', + ), + } + sandbox_info = _make_sandbox_info() + + with patch( + 'openhands.app_server.sandbox.sandbox_router._get_user_context' + ) as mock_ctx: + ctx = AsyncMock() + ctx.get_secrets = AsyncMock(return_value=secrets) + ctx.get_provider_tokens = AsyncMock(return_value={}) + mock_ctx.return_value = ctx + + result = await list_secret_names(sandbox_info=sandbox_info) + + assert isinstance(result, SecretNamesResponse) + assert len(result.secrets) == 2 + names = {s.name for s in result.secrets} + assert 'GITHUB_TOKEN' in names + assert 'MY_API_KEY' in names + + gh = next(s for s in result.secrets if s.name == 'GITHUB_TOKEN') + assert gh.description == 'GitHub personal access token' + # Verify no 'value' field is exposed + assert not hasattr(gh, 'value') + + async def test_returns_empty_when_no_secrets(self): + sandbox_info = _make_sandbox_info() + + with patch( + 'openhands.app_server.sandbox.sandbox_router._get_user_context' + ) as mock_ctx: + ctx = AsyncMock() + ctx.get_secrets = AsyncMock(return_value={}) + ctx.get_provider_tokens = AsyncMock(return_value={}) + mock_ctx.return_value = ctx + + result = await list_secret_names(sandbox_info=sandbox_info) + + assert len(result.secrets) == 0 + + +# --------------------------------------------------------------------------- +# GET /sandboxes/{sandbox_id}/settings/secrets/{name} +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestGetSecretValue: + """Test suite for GET /sandboxes/{sandbox_id}/settings/secrets/{name}.""" + + async def test_returns_raw_secret_value(self): + """Raw secret value returned as plain text.""" + secrets = { + 'GITHUB_TOKEN': StaticSecret( + value=SecretStr('ghp_actual_secret'), + description='GitHub token', + ), + } + sandbox_info = _make_sandbox_info() + + with patch( + 'openhands.app_server.sandbox.sandbox_router._get_user_context' + ) as mock_ctx: + ctx = AsyncMock() + ctx.get_secrets = AsyncMock(return_value=secrets) + ctx.get_provider_tokens = AsyncMock(return_value={}) + mock_ctx.return_value = ctx + + response = await get_secret_value( + secret_name='GITHUB_TOKEN', + sandbox_info=sandbox_info, + ) + + assert response.body == b'ghp_actual_secret' + assert response.media_type == 'text/plain' + + async def test_returns_404_for_unknown_secret(self): + """404 when requested secret doesn't exist in custom secrets or provider tokens.""" + sandbox_info = _make_sandbox_info() + + with patch( + 'openhands.app_server.sandbox.sandbox_router._get_user_context' + ) as mock_ctx: + ctx = AsyncMock() + ctx.get_secrets = AsyncMock(return_value={}) + ctx.get_provider_tokens = AsyncMock(return_value={}) + mock_ctx.return_value = ctx + + with pytest.raises(HTTPException) as exc_info: + await get_secret_value( + secret_name='NONEXISTENT', + sandbox_info=sandbox_info, + ) + + assert exc_info.value.status_code == 404 + + async def test_returns_404_for_none_value_secret(self): + """404 when secret exists but has None value.""" + secrets = { + 'EMPTY_SECRET': StaticSecret(value=None), + } + sandbox_info = _make_sandbox_info() + + with patch( + 'openhands.app_server.sandbox.sandbox_router._get_user_context' + ) as mock_ctx: + ctx = AsyncMock() + ctx.get_secrets = AsyncMock(return_value=secrets) + ctx.get_provider_tokens = AsyncMock(return_value={}) + mock_ctx.return_value = ctx + + with pytest.raises(HTTPException) as exc_info: + await get_secret_value( + secret_name='EMPTY_SECRET', + sandbox_info=sandbox_info, + ) + + assert exc_info.value.status_code == 404 + + +# =========================================================================== +# Integration tests — real HTTP requests through real auth validation logic. +# +# Only the data layer (sandbox service, user context) is mocked. +# The session key validation, ownership checks, and FastAPI routing are REAL. +# =========================================================================== + + +def _build_integration_test_app( + mock_user_context: AsyncMock | None = None, +) -> FastAPI: + """Build a minimal FastAPI app with the real user and sandbox routers. + + The ``depends_user_context`` dependency is overridden with a mock, but the + session key validation logic in ``validate_session_key`` and + ``_validate_session_key_ownership`` runs unmodified. + + Router-level dependencies (e.g. ``check_session_api_key`` from ``SESSION_API_KEY`` + env var) are overridden to no-ops so we can exercise the endpoint-level auth logic + in isolation. + """ + from openhands.app_server.sandbox.sandbox_router import ( + router as sandbox_router, + ) + from openhands.app_server.user.user_router import router as user_router + from openhands.server.dependencies import check_session_api_key + + app = FastAPI() + + # Disable router-level auth (SESSION_API_KEY check) — we're testing the + # endpoint-level session key validation, not the router middleware. + app.dependency_overrides[check_session_api_key] = lambda: None + + if mock_user_context is not None: + from openhands.app_server.user.user_router import user_dependency + + app.dependency_overrides[user_dependency.dependency] = lambda: mock_user_context + + app.include_router(user_router, prefix='/api/v1') + app.include_router(sandbox_router, prefix='/api/v1') + return app + + +class TestExposeSecretsIntegration: + """Integration tests for /users/me?expose_secrets=true via real HTTP. + + These tests exercise the full auth validation stack: + - validate_session_key (real) + - _validate_session_key_ownership (real) + - ownership check (real) + Only the data layer (sandbox service lookup, user context) is mocked. + """ + + def test_expose_secrets_without_session_key_returns_401(self): + """Bearer token alone cannot expose secrets (no X-Session-API-Key).""" + mock_user_ctx = AsyncMock() + mock_user_ctx.get_user_info = AsyncMock( + return_value=UserInfo(id=USER_ID, llm_api_key=SecretStr('sk-secret-123')) + ) + mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID) + + app = _build_integration_test_app(mock_user_ctx) + client = TestClient(app, raise_server_exceptions=False) + + response = client.get('/api/v1/users/me', params={'expose_secrets': 'true'}) + + assert response.status_code == 401 + assert 'X-Session-API-Key' in response.json()['detail'] + + def test_expose_secrets_with_invalid_session_key_returns_401(self): + """Invalid session key (no matching sandbox) is rejected.""" + mock_user_ctx = AsyncMock() + mock_user_ctx.get_user_info = AsyncMock( + return_value=UserInfo(id=USER_ID, llm_api_key=SecretStr('sk-secret-123')) + ) + mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID) + + mock_sandbox_svc = AsyncMock() + mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(return_value=None) + + app = _build_integration_test_app(mock_user_ctx) + client = TestClient(app, raise_server_exceptions=False) + + with patch( + 'openhands.app_server.sandbox.session_auth.get_sandbox_service', + _create_sandbox_service_context_manager(mock_sandbox_svc), + ): + response = client.get( + '/api/v1/users/me', + params={'expose_secrets': 'true'}, + headers={'X-Session-API-Key': 'bogus-key'}, + ) + + assert response.status_code == 401 + assert 'Invalid session API key' in response.json()['detail'] + + def test_expose_secrets_with_wrong_user_returns_403(self): + """Session key from a different user's sandbox is rejected.""" + mock_user_ctx = AsyncMock() + mock_user_ctx.get_user_info = AsyncMock( + return_value=UserInfo(id='user-A', llm_api_key=SecretStr('sk-secret-123')) + ) + mock_user_ctx.get_user_id = AsyncMock(return_value='user-A') + + # Sandbox owned by user-B + sandbox_b = _make_sandbox_info(user_id='user-B') + mock_sandbox_svc = AsyncMock() + mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock( + return_value=sandbox_b + ) + + app = _build_integration_test_app(mock_user_ctx) + client = TestClient(app, raise_server_exceptions=False) + + with patch( + 'openhands.app_server.sandbox.session_auth.get_sandbox_service', + _create_sandbox_service_context_manager(mock_sandbox_svc), + ): + response = client.get( + '/api/v1/users/me', + params={'expose_secrets': 'true'}, + headers={'X-Session-API-Key': 'stolen-key'}, + ) + + assert response.status_code == 403 + assert 'does not belong' in response.json()['detail'] + + def test_expose_secrets_valid_dual_auth_returns_200_unmasked(self): + """Valid Bearer + valid session key owned by same user → 200 with secrets.""" + mock_user_ctx = AsyncMock() + mock_user_ctx.get_user_info = AsyncMock( + return_value=UserInfo( + id=USER_ID, + llm_model='anthropic/claude-sonnet-4-20250514', + llm_api_key=SecretStr('sk-real-secret'), + llm_base_url='https://litellm.example.com', + ) + ) + mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID) + + sandbox = _make_sandbox_info(user_id=USER_ID) + mock_sandbox_svc = AsyncMock() + mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock( + return_value=sandbox + ) + + app = _build_integration_test_app(mock_user_ctx) + client = TestClient(app, raise_server_exceptions=False) + + with patch( + 'openhands.app_server.sandbox.session_auth.get_sandbox_service', + _create_sandbox_service_context_manager(mock_sandbox_svc), + ): + response = client.get( + '/api/v1/users/me', + params={'expose_secrets': 'true'}, + headers={'X-Session-API-Key': 'valid-key'}, + ) + + assert response.status_code == 200 + body = response.json() + assert body['llm_api_key'] == 'sk-real-secret' + assert body['llm_model'] == 'anthropic/claude-sonnet-4-20250514' + assert body['llm_base_url'] == 'https://litellm.example.com' + + def test_default_masks_secrets_via_http(self): + """Without expose_secrets, secrets are masked even via real HTTP.""" + mock_user_ctx = AsyncMock() + mock_user_ctx.get_user_info = AsyncMock( + return_value=UserInfo( + id=USER_ID, llm_api_key=SecretStr('sk-should-be-masked') + ) + ) + + app = _build_integration_test_app(mock_user_ctx) + client = TestClient(app, raise_server_exceptions=False) + + response = client.get('/api/v1/users/me') + + assert response.status_code == 200 + body = response.json() + assert body['llm_api_key'] == '**********' + + +class TestSandboxSecretsIntegration: + """Integration tests for sandbox-scoped secrets endpoints via real HTTP. + + The session key validation in ``_valid_sandbox_from_session_key`` runs + unmodified — only the sandbox service (database) is mocked. + """ + + def test_secrets_list_without_session_key_returns_401(self): + """Missing X-Session-API-Key on secrets endpoint is rejected.""" + app = _build_integration_test_app() + client = TestClient(app, raise_server_exceptions=False) + + response = client.get(f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets') + + assert response.status_code == 401 + assert 'X-Session-API-Key' in response.json()['detail'] + + def test_secrets_list_with_invalid_session_key_returns_401(self): + """Invalid session key on secrets endpoint is rejected.""" + app = _build_integration_test_app() + client = TestClient(app, raise_server_exceptions=False) + + mock_sandbox_svc = AsyncMock() + mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock(return_value=None) + + with patch( + 'openhands.app_server.sandbox.session_auth.get_sandbox_service', + _create_sandbox_service_context_manager(mock_sandbox_svc), + ): + response = client.get( + f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets', + headers={'X-Session-API-Key': 'bogus'}, + ) + + assert response.status_code == 401 + assert 'Invalid session API key' in response.json()['detail'] + + def test_secrets_list_with_mismatched_sandbox_id_returns_403(self): + """Session key maps to a different sandbox than the URL path → 403.""" + app = _build_integration_test_app() + client = TestClient(app, raise_server_exceptions=False) + + # Session key maps to sandbox "other-sandbox", but URL says SANDBOX_ID + other_sandbox = _make_sandbox_info(sandbox_id='other-sandbox') + mock_sandbox_svc = AsyncMock() + mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock( + return_value=other_sandbox + ) + + with patch( + 'openhands.app_server.sandbox.session_auth.get_sandbox_service', + _create_sandbox_service_context_manager(mock_sandbox_svc), + ): + response = client.get( + f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets', + headers={'X-Session-API-Key': 'valid-key'}, + ) + + assert response.status_code == 403 + assert 'does not match' in response.json()['detail'] + + def test_sandbox_without_user_returns_401_for_secret_value(self): + """Sandbox with no owning user → 401 when fetching a secret value.""" + app = _build_integration_test_app() + client = TestClient(app, raise_server_exceptions=False) + + # Sandbox exists but has no owning user + sandbox_no_user = _make_sandbox_info(user_id=None) + mock_sandbox_svc = AsyncMock() + mock_sandbox_svc.get_sandbox_by_session_api_key = AsyncMock( + return_value=sandbox_no_user + ) + + with patch( + 'openhands.app_server.sandbox.session_auth.get_sandbox_service', + _create_sandbox_service_context_manager(mock_sandbox_svc), + ): + response = client.get( + f'/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets/MY_SECRET', + headers={'X-Session-API-Key': 'valid-key'}, + ) + + # _get_user_context raises 401 because created_by_user_id is None + assert response.status_code == 401 + assert 'no associated user' in response.json()['detail'] + + +# --------------------------------------------------------------------------- +# Provider tokens in sandbox secrets endpoints +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestProviderTokensInEndpoints: + """Verify that sandbox secrets endpoints include provider tokens resolved lazily.""" + + async def test_get_provider_tokens_as_env_vars(self): + """get_provider_tokens(as_env_vars=True) returns fresh values keyed by env name.""" + mock_user_auth = AsyncMock() + mock_user_auth.get_provider_tokens = AsyncMock( + return_value={ + ProviderType.GITHUB: ProviderToken(token=SecretStr('ghp_test123')), + ProviderType.GITLAB: ProviderToken(token=SecretStr('glpat-test456')), + } + ) + + ctx = AuthUserContext(user_auth=mock_user_auth) + result = await ctx.get_provider_tokens(as_env_vars=True) + + gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB) + gl_key = ProviderHandler.get_provider_env_key(ProviderType.GITLAB) + assert result[gh_key] == 'ghp_test123' + assert result[gl_key] == 'glpat-test456' + + async def test_empty_provider_tokens_excluded(self): + """Provider tokens with empty token values are excluded.""" + mock_user_auth = AsyncMock() + mock_user_auth.get_provider_tokens = AsyncMock( + return_value={ + ProviderType.GITHUB: ProviderToken(token=SecretStr('')), + } + ) + + ctx = AuthUserContext(user_auth=mock_user_auth) + result = await ctx.get_provider_tokens(as_env_vars=True) + + gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB) + assert gh_key not in result + + async def test_none_provider_tokens_returns_empty(self): + """get_provider_tokens(as_env_vars=True) with None tokens yields empty dict.""" + mock_user_auth = AsyncMock() + mock_user_auth.get_provider_tokens = AsyncMock(return_value=None) + + ctx = AuthUserContext(user_auth=mock_user_auth) + result = await ctx.get_provider_tokens(as_env_vars=True) + assert result == {} + + async def test_list_secret_names_includes_provider_tokens(self): + """list_secret_names returns both custom secrets and provider token names.""" + custom_secrets = { + 'MY_KEY': StaticSecret( + value=SecretStr('my-value'), description='custom key' + ), + } + gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB) + provider_env_vars = {gh_key: 'ghp_test123'} + + sandbox_info = _make_sandbox_info() + + with patch( + 'openhands.app_server.sandbox.sandbox_router._get_user_context' + ) as mock_ctx: + ctx = AsyncMock() + ctx.get_secrets = AsyncMock(return_value=custom_secrets) + ctx.get_provider_tokens = AsyncMock(return_value=provider_env_vars) + mock_ctx.return_value = ctx + + result = await list_secret_names(sandbox_info=sandbox_info) + + names = {s.name for s in result.secrets} + assert 'MY_KEY' in names + assert gh_key in names + assert len(result.secrets) == 2 + + async def test_get_secret_value_resolves_provider_token(self): + """get_secret_value falls back to provider tokens when not in custom secrets.""" + gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB) + sandbox_info = _make_sandbox_info() + + with patch( + 'openhands.app_server.sandbox.sandbox_router._get_user_context' + ) as mock_ctx: + ctx = AsyncMock() + ctx.get_secrets = AsyncMock(return_value={}) + ctx.get_provider_tokens = AsyncMock( + return_value={gh_key: 'ghp_fresh_token'} + ) + mock_ctx.return_value = ctx + + response = await get_secret_value( + secret_name=gh_key, sandbox_info=sandbox_info + ) + + assert response.body == b'ghp_fresh_token' + assert response.media_type == 'text/plain' + + async def test_custom_secret_takes_priority_over_provider_token(self): + """If a custom secret has the same name, it takes priority.""" + gh_key = ProviderHandler.get_provider_env_key(ProviderType.GITHUB) + sandbox_info = _make_sandbox_info() + + with patch( + 'openhands.app_server.sandbox.sandbox_router._get_user_context' + ) as mock_ctx: + ctx = AsyncMock() + ctx.get_secrets = AsyncMock( + return_value={ + gh_key: StaticSecret( + value=SecretStr('custom-override'), + description='user override', + ) + } + ) + # Provider token should NOT be called since custom secret matches + ctx.get_provider_tokens = AsyncMock(return_value={gh_key: 'provider-value'}) + mock_ctx.return_value = ctx + + response = await get_secret_value( + secret_name=gh_key, sandbox_info=sandbox_info + ) + + assert response.body == b'custom-override' From 38099934b6927aed7202cf257b7506954859061b Mon Sep 17 00:00:00 2001 From: Neha Prasad Date: Tue, 17 Mar 2026 19:29:02 +0530 Subject: [PATCH 39/92] fix : planner PLAN.md rendering and search labels (#13418) Co-authored-by: hieptl --- .../hooks/use-plan-preview-events.test.ts | 28 +++++- .../get-action-content.ts | 30 +++++++ .../get-event-content.tsx | 18 ++++ .../v1/chat/hooks/use-plan-preview-events.ts | 13 ++- .../conversation-websocket-context.tsx | 60 +++++++------ frontend/src/i18n/declaration.ts | 2 + frontend/src/i18n/translation.json | 88 +++++++++++++------ frontend/src/types/v1/core/base/action.ts | 30 ++++++- frontend/src/types/v1/core/base/base.ts | 6 +- 9 files changed, 212 insertions(+), 63 deletions(-) diff --git a/frontend/__tests__/components/v1/chat/hooks/use-plan-preview-events.test.ts b/frontend/__tests__/components/v1/chat/hooks/use-plan-preview-events.test.ts index ee5f61556e..438140fdb5 100644 --- a/frontend/__tests__/components/v1/chat/hooks/use-plan-preview-events.test.ts +++ b/frontend/__tests__/components/v1/chat/hooks/use-plan-preview-events.test.ts @@ -28,6 +28,7 @@ const createUserMessageEvent = (id: string): MessageEvent => ({ const createPlanningObservationEvent = ( id: string, actionId: string = "action-1", + path: string = "/workspace/PLAN.md", ): ObservationEvent => ({ id, timestamp: new Date().toISOString(), @@ -40,7 +41,7 @@ const createPlanningObservationEvent = ( content: [{ type: "text", text: "Plan content" }], is_error: false, command: "create", - path: "/workspace/PLAN.md", + path, prev_exist: false, old_content: null, new_content: "Plan content", @@ -172,6 +173,31 @@ describe("usePlanPreviewEvents", () => { expect(result.current.size).toBe(1); expect(result.current.has("plan-obs-1")).toBe(true); }); + + it("should exclude PlanningFileEditorObservation for non-Plan.md paths", () => { + const events: OpenHandsEvent[] = [ + createUserMessageEvent("user-1"), + createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"), + createPlanningObservationEvent("plan-obs-2", "action-2", "use-add-mcp.ts"), + ]; + + const { result } = renderHook(() => usePlanPreviewEvents(events)); + + expect(result.current.size).toBe(0); + }); + + it("should include only Plan.md observations when mixed with other file edits", () => { + const events: OpenHandsEvent[] = [ + createUserMessageEvent("user-1"), + createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"), + createPlanningObservationEvent("plan-obs-2", "action-2", "/workspace/PLAN.md"), + ]; + + const { result } = renderHook(() => usePlanPreviewEvents(events)); + + expect(result.current.size).toBe(1); + expect(result.current.has("plan-obs-2")).toBe(true); + }); }); describe("shouldShowPlanPreview", () => { diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts index 1489493652..62e907de27 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts @@ -21,6 +21,8 @@ import { BrowserListTabsAction, BrowserSwitchTabAction, BrowserCloseTabAction, + GlobAction, + GrepAction, } from "#/types/v1/core/base/action"; const getRiskText = (risk: SecurityRisk) => { @@ -39,6 +41,28 @@ const getRiskText = (risk: SecurityRisk) => { const getNoContentActionContent = (): string => ""; +// Grep/Glob search actions +const getSearchActionContent = ( + event: ActionEvent, +): string => { + const { action } = event; + const parts: string[] = []; + if (action.pattern) { + parts.push(`**Pattern:** \`${action.pattern}\``); + } + if (action.path) { + parts.push(`**Path:** \`${action.path}\``); + } + if ("include" in action && action.include) { + parts.push(`**Include:** \`${action.include}\``); + } + const { summary } = event as { summary?: string }; + if (summary) { + parts.push(`**Summary:** ${summary}`); + } + return parts.length > 0 ? parts.join("\n") : getNoContentActionContent(); +}; + // File Editor Actions const getFileEditorActionContent = ( action: FileEditorAction | StrReplaceEditorAction, @@ -228,6 +252,12 @@ export const getActionContent = (event: ActionEvent): string => { case "BrowserCloseTabAction": return getBrowserActionContent(action); + case "GrepAction": + case "GlobAction": + return getSearchActionContent( + event as ActionEvent, + ); + default: return getDefaultEventContent(event); } diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx index 88f546d5b1..55e9e939db 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx +++ b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx @@ -84,6 +84,24 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => { case "TaskTrackerAction": actionKey = "ACTION_MESSAGE$TASK_TRACKING"; break; + case "GrepAction": + actionKey = "ACTION_MESSAGE$GREP"; + actionValues = { + pattern: + "pattern" in event.action && event.action.pattern + ? trimText(String(event.action.pattern), 50) + : "", + }; + break; + case "GlobAction": + actionKey = "ACTION_MESSAGE$GLOB"; + actionValues = { + pattern: + "pattern" in event.action && event.action.pattern + ? trimText(String(event.action.pattern), 50) + : "", + }; + break; case "BrowserNavigateAction": case "BrowserClickAction": case "BrowserTypeAction": diff --git a/frontend/src/components/v1/chat/hooks/use-plan-preview-events.ts b/frontend/src/components/v1/chat/hooks/use-plan-preview-events.ts index b36c97786b..60cee582cd 100644 --- a/frontend/src/components/v1/chat/hooks/use-plan-preview-events.ts +++ b/frontend/src/components/v1/chat/hooks/use-plan-preview-events.ts @@ -38,19 +38,24 @@ function groupEventsByPhase(events: OpenHandsEvent[]): OpenHandsEvent[][] { return phases; } +const isPlanFilePath = (path: string | null): boolean => + path?.toUpperCase().endsWith("PLAN.MD") ?? false; + /** - * Finds the last PlanningFileEditorObservation in a phase. + * Finds the last PlanningFileEditorObservation for Plan.md in a phase. * * @param phase - Array of events in a phase - * @returns The event ID of the last PlanningFileEditorObservation, or null + * @returns The event ID of the last Plan.md observation, or null */ function findLastPlanningObservationInPhase( phase: OpenHandsEvent[], ): string | null { - // Iterate backwards to find the last one for (let i = phase.length - 1; i >= 0; i -= 1) { const event = phase[i]; - if (isPlanningFileEditorObservationEvent(event)) { + if ( + isPlanningFileEditorObservationEvent(event) && + isPlanFilePath(event.observation.path) + ) { return event.id; } } diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index 33c7169a77..3e56c3d289 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -126,13 +126,15 @@ export function ConversationWebSocketProvider({ const receivedEventCountRefMain = useRef(0); const receivedEventCountRefPlanning = useRef(0); - // Track the latest PlanningFileEditorObservation event during history replay - // We'll only call the API once after history loading completes + // Track the latest PlanningFileEditorObservation for Plan.md during history replay const latestPlanningFileEventRef = useRef<{ path: string; conversationId: string; } | null>(null); + const isPlanFilePath = (path: string | null): boolean => + path?.toUpperCase().endsWith("PLAN.MD") ?? false; + // Helper function to update metrics from stats event const updateMetricsFromStats = useCallback( (event: ConversationStateUpdateEventStats) => { @@ -612,37 +614,39 @@ export function ConversationWebSocketProvider({ appendOutput(textContent); } - // Handle PlanningFileEditorObservation events - read and update plan content + // Handle PlanningFileEditorObservation - only update plan for Plan.md if (isPlanningFileEditorObservationEvent(event)) { - const planningAgentConversation = subConversations?.[0]; - const planningConversationId = planningAgentConversation?.id; + const { path } = event.observation; + if (isPlanFilePath(path)) { + const planningAgentConversation = subConversations?.[0]; + const planningConversationId = planningAgentConversation?.id; - if (planningConversationId && event.observation.path) { - // During history replay, track the latest event but don't call API - // After history loading completes, we'll call the API once with the latest event - if (isLoadingHistoryPlanning) { - latestPlanningFileEventRef.current = { - path: event.observation.path, - conversationId: planningConversationId, - }; - } else { - // History loading is complete - this is a new real-time event - // Call the API immediately for real-time updates - readConversationFile( - { + if (planningConversationId && path) { + if (isLoadingHistoryPlanning) { + latestPlanningFileEventRef.current = { + path, conversationId: planningConversationId, - filePath: event.observation.path, - }, - { - onSuccess: (fileContent) => { - setPlanContent(fileContent); + }; + } else { + readConversationFile( + { + conversationId: planningConversationId, + filePath: path, }, - onError: (error) => { - // eslint-disable-next-line no-console - console.warn("Failed to read conversation file:", error); + { + onSuccess: (fileContent) => { + setPlanContent(fileContent); + }, + onError: (error) => { + // eslint-disable-next-line no-console + console.warn( + "Failed to read conversation file:", + error, + ); + }, }, - }, - ); + ); + } } } } diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 25bb3a7c37..51f1bd288c 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -533,6 +533,8 @@ export enum I18nKey { ACTION_MESSAGE$SYSTEM = "ACTION_MESSAGE$SYSTEM", ACTION_MESSAGE$CONDENSATION = "ACTION_MESSAGE$CONDENSATION", ACTION_MESSAGE$TASK_TRACKING = "ACTION_MESSAGE$TASK_TRACKING", + ACTION_MESSAGE$GREP = "ACTION_MESSAGE$GREP", + ACTION_MESSAGE$GLOB = "ACTION_MESSAGE$GLOB", OBSERVATION_MESSAGE$RUN = "OBSERVATION_MESSAGE$RUN", OBSERVATION_MESSAGE$RUN_IPYTHON = "OBSERVATION_MESSAGE$RUN_IPYTHON", OBSERVATION_MESSAGE$READ = "OBSERVATION_MESSAGE$READ", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 466e9b01cc..7455ad2436 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -8527,6 +8527,38 @@ "tr": "Görevleri yönetiyor", "uk": "Керування завданнями" }, + "ACTION_MESSAGE$GREP": { + "en": "Search in files: {{pattern}}", + "ja": "ファイル内検索: {{pattern}}", + "zh-CN": "在文件中搜索: {{pattern}}", + "zh-TW": "在檔案中搜尋: {{pattern}}", + "ko-KR": "파일 내 검색: {{pattern}}", + "no": "Søk i filer: {{pattern}}", + "it": "Cerca nei file: {{pattern}}", + "pt": "Pesquisar em arquivos: {{pattern}}", + "es": "Buscar en archivos: {{pattern}}", + "ar": "البحث داخل الملفات: {{pattern}}", + "fr": "Rechercher dans les fichiers: {{pattern}}", + "tr": "Dosyalarda ara: {{pattern}}", + "de": "In Dateien suchen: {{pattern}}", + "uk": "Пошук у файлах: {{pattern}}" + }, + "ACTION_MESSAGE$GLOB": { + "en": "Search files: {{pattern}}", + "ja": "ファイル検索: {{pattern}}", + "zh-CN": "搜索文件: {{pattern}}", + "zh-TW": "搜尋檔案: {{pattern}}", + "ko-KR": "파일 검색: {{pattern}}", + "no": "Søk filer: {{pattern}}", + "it": "Cerca file: {{pattern}}", + "pt": "Pesquisar arquivos: {{pattern}}", + "es": "Buscar archivos: {{pattern}}", + "ar": "البحث في الملفات: {{pattern}}", + "fr": "Rechercher des fichiers: {{pattern}}", + "tr": "Dosya ara: {{pattern}}", + "de": "Dateien suchen: {{pattern}}", + "uk": "Пошук файлів: {{pattern}}" + }, "OBSERVATION_MESSAGE$RUN": { "en": "Ran {{command}}", "zh-CN": "运行 {{command}}", @@ -8672,36 +8704,36 @@ "uk": "Думка" }, "OBSERVATION_MESSAGE$GLOB": { - "en": "Glob: {{pattern}}", - "ja": "Glob: {{pattern}}", - "zh-CN": "Glob: {{pattern}}", - "zh-TW": "Glob: {{pattern}}", - "ko-KR": "Glob: {{pattern}}", - "no": "Glob: {{pattern}}", - "it": "Glob: {{pattern}}", - "pt": "Glob: {{pattern}}", - "es": "Glob: {{pattern}}", - "ar": "Glob: {{pattern}}", - "fr": "Glob: {{pattern}}", - "tr": "Glob: {{pattern}}", - "de": "Glob: {{pattern}}", - "uk": "Glob: {{pattern}}" + "en": "Search files: {{pattern}}", + "ja": "ファイル検索: {{pattern}}", + "zh-CN": "搜索文件: {{pattern}}", + "zh-TW": "搜尋檔案: {{pattern}}", + "ko-KR": "파일 검색: {{pattern}}", + "no": "Søk filer: {{pattern}}", + "it": "Cerca file: {{pattern}}", + "pt": "Pesquisar arquivos: {{pattern}}", + "es": "Buscar archivos: {{pattern}}", + "ar": "البحث في الملفات: {{pattern}}", + "fr": "Rechercher des fichiers: {{pattern}}", + "tr": "Dosya ara: {{pattern}}", + "de": "Dateien suchen: {{pattern}}", + "uk": "Пошук файлів: {{pattern}}" }, "OBSERVATION_MESSAGE$GREP": { - "en": "Grep: {{pattern}}", - "ja": "Grep: {{pattern}}", - "zh-CN": "Grep: {{pattern}}", - "zh-TW": "Grep: {{pattern}}", - "ko-KR": "Grep: {{pattern}}", - "no": "Grep: {{pattern}}", - "it": "Grep: {{pattern}}", - "pt": "Grep: {{pattern}}", - "es": "Grep: {{pattern}}", - "ar": "Grep: {{pattern}}", - "fr": "Grep: {{pattern}}", - "tr": "Grep: {{pattern}}", - "de": "Grep: {{pattern}}", - "uk": "Grep: {{pattern}}" + "en": "Search in files: {{pattern}}", + "ja": "ファイル内検索: {{pattern}}", + "zh-CN": "在文件中搜索: {{pattern}}", + "zh-TW": "在檔案中搜尋: {{pattern}}", + "ko-KR": "파일 내 검색: {{pattern}}", + "no": "Søk i filer: {{pattern}}", + "it": "Cerca nei file: {{pattern}}", + "pt": "Pesquisar em arquivos: {{pattern}}", + "es": "Buscar en archivos: {{pattern}}", + "ar": "البحث داخل الملفات: {{pattern}}", + "fr": "Rechercher dans les fichiers: {{pattern}}", + "tr": "Dosyalarda ara: {{pattern}}", + "de": "In Dateien suchen: {{pattern}}", + "uk": "Пошук у файлах: {{pattern}}" }, "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN": { "en": "Agent updated the plan", diff --git a/frontend/src/types/v1/core/base/action.ts b/frontend/src/types/v1/core/base/action.ts index 2fcef92087..d2e0b9af88 100644 --- a/frontend/src/types/v1/core/base/action.ts +++ b/frontend/src/types/v1/core/base/action.ts @@ -244,6 +244,32 @@ export interface PlanningFileEditorAction extends ActionBase<"PlanningFileEditor view_range: [number, number] | null; } +export interface GlobAction extends ActionBase<"GlobAction"> { + /** + * The glob pattern to match files against. + */ + pattern: string; + /** + * The directory to search in. + */ + path: string | null; +} + +export interface GrepAction extends ActionBase<"GrepAction"> { + /** + * The regex pattern to search for in file contents. + */ + pattern: string; + /** + * The file or directory to search in. + */ + path: string | null; + /** + * Glob pattern to filter files. + */ + include: string | null; +} + export type Action = | MCPToolAction | FinishAction @@ -263,4 +289,6 @@ export type Action = | BrowserGoBackAction | BrowserListTabsAction | BrowserSwitchTabAction - | BrowserCloseTabAction; + | BrowserCloseTabAction + | GlobAction + | GrepAction; diff --git a/frontend/src/types/v1/core/base/base.ts b/frontend/src/types/v1/core/base/base.ts index 531168d17b..1ab4492174 100644 --- a/frontend/src/types/v1/core/base/base.ts +++ b/frontend/src/types/v1/core/base/base.ts @@ -23,7 +23,11 @@ type ActionOnlyType = type ObservationOnlyType = "Browser"; -type ActionEventType = `${ActionOnlyType}Action` | `${EventType}Action`; +type ActionEventType = + | `${ActionOnlyType}Action` + | `${EventType}Action` + | "GlobAction" + | "GrepAction"; type ObservationEventType = | `${ObservationOnlyType}Observation` | `${EventType}Observation` From 8a7779068a3ece16f3221263a29ec67c434a37a6 Mon Sep 17 00:00:00 2001 From: Kooltek68 <80048456+travellingsoldier85@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:16:31 +0000 Subject: [PATCH 40/92] docs: fix typo in README.md (#13444) --- enterprise/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise/README.md b/enterprise/README.md index 01c5b9a64f..497ae9a7be 100644 --- a/enterprise/README.md +++ b/enterprise/README.md @@ -51,6 +51,6 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa ## User ID vs User Token - In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app -- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead) +- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completely ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead) Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages. From bd837039dd8ae2babaacabd68f533307f5bd46cc Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Tue, 17 Mar 2026 15:45:50 +0100 Subject: [PATCH 41/92] chore: update skills path comments (#12794) --- .../app_server/app_conversation/app_conversation_router.py | 2 +- .../app_conversation/app_conversation_service_base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py index 6babd41dc0..d3ad901db7 100644 --- a/openhands/app_server/app_conversation/app_conversation_router.py +++ b/openhands/app_server/app_conversation/app_conversation_router.py @@ -583,7 +583,7 @@ async def get_conversation_skills( - Global skills (OpenHands/skills/) - User skills (~/.openhands/skills/) - Organization skills (org/.openhands repository) - - Repository skills (repo/.openhands/skills/ or .openhands/microagents/) + - Repository skills (repo .agents/skills/, .openhands/microagents/, and legacy .openhands/skills/) Returns: JSONResponse: A JSON response containing the list of skills. diff --git a/openhands/app_server/app_conversation/app_conversation_service_base.py b/openhands/app_server/app_conversation/app_conversation_service_base.py index 0d66e16a4f..97cfa30087 100644 --- a/openhands/app_server/app_conversation/app_conversation_service_base.py +++ b/openhands/app_server/app_conversation/app_conversation_service_base.py @@ -105,7 +105,7 @@ class AppConversationServiceBase(AppConversationService, ABC): - Public skills (from OpenHands/skills GitHub repo) - User skills (from ~/.openhands/skills/) - Organization skills (from {org}/.openhands repo) - - Project/repo skills (from workspace .openhands/skills/) + - Project/repo skills (from repo .agents/skills/, .openhands/microagents/, and legacy .openhands/skills/) - Sandbox skills (from exposed URLs) Args: From d58e12ad74598f1d8adb3f0f4a55ec6ad1048a09 Mon Sep 17 00:00:00 2001 From: aivong-openhands Date: Tue, 17 Mar 2026 10:13:08 -0500 Subject: [PATCH 42/92] Fix CVE-2026-27962: Update authlib to 1.6.9 (#13439) Co-authored-by: OpenHands CVE Fix Bot Co-authored-by: OpenHands Bot --- enterprise/poetry.lock | 6 +++--- poetry.lock | 8 ++++---- pyproject.toml | 4 ++-- uv.lock | 10 +++++----- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index e4ac41f488..395c14bd12 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -602,14 +602,14 @@ files = [ [[package]] name = "authlib" -version = "1.6.7" +version = "1.6.9" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0"}, - {file = "authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b"}, + {file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"}, + {file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"}, ] [package.dependencies] diff --git a/poetry.lock b/poetry.lock index 8654e22e78..82082a80c4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -606,14 +606,14 @@ files = [ [[package]] name = "authlib" -version = "1.6.7" +version = "1.6.9" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0"}, - {file = "authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b"}, + {file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"}, + {file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"}, ] [package.dependencies] @@ -14833,4 +14833,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api [metadata] lock-version = "2.1" python-versions = "^3.12,<3.14" -content-hash = "b8a9c6245f0c3cabfeaffe6eb7c1fae76391a15533c18bce1fe168e070a66d63" +content-hash = "1a8151b36fb64667d1a2e83f38060841de15bd0284f18e8f58c6ee95095e933e" diff --git a/pyproject.toml b/pyproject.toml index b2958f646c..87609dbf9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "anthropic[vertex]", "anyio==4.9", "asyncpg>=0.30", - "authlib>=1.6.7", + "authlib>=1.6.9", "bashlex>=0.18", "boto3", "browsergym-core==0.13.3", @@ -163,7 +163,7 @@ include = [ [tool.poetry.dependencies] python = "^3.12,<3.14" -authlib = ">=1.6.7" # Pinned to fix CVE-2026-28802 +authlib = ">=1.6.9" # CVE-2026-27962 (fixed in 1.6.9) orjson = ">=3.11.6" # Pinned to fix CVE-2025-67221 litellm = ">=1.74.3, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272) openai = "2.8.0" # Pin due to litellm incompatibility with >=1.100.0 (BerriAI/litellm#13711) diff --git a/uv.lock b/uv.lock index 36aef2fc9e..34e19fdd73 100644 --- a/uv.lock +++ b/uv.lock @@ -360,14 +360,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.7" +version = "1.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] [[package]] @@ -3792,7 +3792,7 @@ requires-dist = [ { name = "anthropic", extras = ["vertex"] }, { name = "anyio", specifier = "==4.9" }, { name = "asyncpg", specifier = ">=0.30" }, - { name = "authlib", specifier = ">=1.6.7" }, + { name = "authlib", specifier = ">=1.6.9" }, { name = "bashlex", specifier = ">=0.18" }, { name = "boto3" }, { name = "browsergym-core", specifier = "==0.13.3" }, @@ -3844,7 +3844,7 @@ requires-dist = [ { name = "psutil" }, { name = "pybase62", specifier = ">=1" }, { name = "pygithub", specifier = ">=2.5" }, - { name = "pyjwt", specifier = ">=2.12.0" }, + { name = "pyjwt", specifier = ">=2.12" }, { name = "pylatexenc" }, { name = "pypdf", specifier = ">=6.7.2" }, { name = "python-docx" }, From b68c75252dd56c05f503697e1c942921229d5ff5 Mon Sep 17 00:00:00 2001 From: Saurya Velagapudi Date: Tue, 17 Mar 2026 08:52:40 -0700 Subject: [PATCH 43/92] Add architecture diagrams explaining system components and WebSocket flow (#12542) Co-authored-by: openhands Co-authored-by: Saurya Co-authored-by: Ray Myers --- enterprise/doc/architecture/README.md | 13 +++ enterprise/doc/architecture/authentication.md | 58 ++++++++++++ .../doc/architecture/external-integrations.md | 88 ++++++++++++++++++ openhands/README.md | 10 +- openhands/architecture/README.md | 10 ++ openhands/architecture/agent-execution.md | 92 +++++++++++++++++++ .../architecture/conversation-startup.md | 68 ++++++++++++++ openhands/architecture/observability.md | 85 +++++++++++++++++ openhands/architecture/system-architecture.md | 88 ++++++++++++++++++ 9 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 enterprise/doc/architecture/README.md create mode 100644 enterprise/doc/architecture/authentication.md create mode 100644 enterprise/doc/architecture/external-integrations.md create mode 100644 openhands/architecture/README.md create mode 100644 openhands/architecture/agent-execution.md create mode 100644 openhands/architecture/conversation-startup.md create mode 100644 openhands/architecture/observability.md create mode 100644 openhands/architecture/system-architecture.md diff --git a/enterprise/doc/architecture/README.md b/enterprise/doc/architecture/README.md new file mode 100644 index 0000000000..47d0217e71 --- /dev/null +++ b/enterprise/doc/architecture/README.md @@ -0,0 +1,13 @@ +# Enterprise Architecture Documentation + +Architecture diagrams specific to the OpenHands SaaS/Enterprise deployment. + +## Documentation + +- [Authentication Flow](./authentication.md) - Keycloak-based authentication for SaaS deployment +- [External Integrations](./external-integrations.md) - GitHub, Slack, Jira, and other service integrations + +## Related Documentation + +For core OpenHands architecture (applicable to all deployments), see: +- [Core Architecture Documentation](../../../openhands/architecture/README.md) diff --git a/enterprise/doc/architecture/authentication.md b/enterprise/doc/architecture/authentication.md new file mode 100644 index 0000000000..dedb201ae3 --- /dev/null +++ b/enterprise/doc/architecture/authentication.md @@ -0,0 +1,58 @@ +# Authentication Flow (SaaS Deployment) + +OpenHands uses Keycloak for identity management in the SaaS deployment. The authentication flow involves multiple services: + +```mermaid +sequenceDiagram + autonumber + participant User as User (Browser) + participant App as App Server + participant KC as Keycloak + participant IdP as Identity Provider
(GitHub, Google, etc.) + participant DB as User Database + + Note over User,DB: OAuth 2.0 / OIDC Authentication Flow + + User->>App: Access OpenHands + App->>User: Redirect to Keycloak + User->>KC: Login request + KC->>User: Show login options + User->>KC: Select provider (e.g., GitHub) + KC->>IdP: OAuth redirect + User->>IdP: Authenticate + IdP-->>KC: OAuth callback + tokens + Note over KC: Create/update user session + KC-->>User: Redirect with auth code + User->>App: Auth code + App->>KC: Exchange code for tokens + KC-->>App: Access token + Refresh token + Note over App: Create signed JWT cookie + App->>DB: Store/update user record + App-->>User: Set keycloak_auth cookie + + Note over User,DB: Subsequent Requests + + User->>App: Request with cookie + Note over App: Verify JWT signature + App->>KC: Validate token (if needed) + KC-->>App: Token valid + Note over App: Extract user context + App-->>User: Authorized response +``` + +### Authentication Components + +| Component | Purpose | Location | +|-----------|---------|----------| +| **Keycloak** | Identity provider, SSO, token management | External service | +| **UserAuth** | Abstract auth interface | `openhands/server/user_auth/user_auth.py` | +| **SaasUserAuth** | Keycloak implementation | `enterprise/server/auth/saas_user_auth.py` | +| **JWT Service** | Token signing/verification | `openhands/app_server/services/jwt_service.py` | +| **Auth Routes** | Login/logout endpoints | `enterprise/server/routes/auth.py` | + +### Token Flow + +1. **Keycloak Access Token**: Short-lived token for API access +2. **Keycloak Refresh Token**: Long-lived token to obtain new access tokens +3. **Signed JWT Cookie**: App Server's session cookie containing encrypted Keycloak tokens +4. **Provider Tokens**: OAuth tokens for GitHub, GitLab, etc. (stored separately for git operations) diff --git a/enterprise/doc/architecture/external-integrations.md b/enterprise/doc/architecture/external-integrations.md new file mode 100644 index 0000000000..d5e16a7590 --- /dev/null +++ b/enterprise/doc/architecture/external-integrations.md @@ -0,0 +1,88 @@ +# External Integrations + +OpenHands integrates with external services (GitHub, Slack, Jira, etc.) through webhook-based event handling: + +```mermaid +sequenceDiagram + autonumber + participant Ext as External Service
(GitHub/Slack/Jira) + participant App as App Server + participant IntRouter as Integration Router + participant Manager as Integration Manager + participant Conv as Conversation Service + participant Sandbox as Sandbox + + Note over Ext,Sandbox: Webhook Event Flow (e.g., GitHub Issue Created) + + Ext->>App: POST /api/integration/{service}/events + App->>IntRouter: Route to service handler + Note over IntRouter: Verify signature (HMAC) + + IntRouter->>Manager: Parse event payload + Note over Manager: Extract context (repo, issue, user) + Note over Manager: Map external user → OpenHands user + + Manager->>Conv: Create conversation (with issue context) + Conv->>Sandbox: Provision sandbox + Sandbox-->>Conv: Ready + + Manager->>Sandbox: Start agent with task + + Note over Ext,Sandbox: Agent Works on Task... + + Sandbox-->>Manager: Task complete + Manager->>Ext: POST result
(PR, comment, etc.) + + Note over Ext,Sandbox: Callback Flow (Agent → External Service) + + Sandbox->>App: Webhook callback
/api/v1/webhooks + App->>Manager: Process callback + Manager->>Ext: Update external service +``` + +### Supported Integrations + +| Integration | Trigger Events | Agent Actions | +|-------------|----------------|---------------| +| **GitHub** | Issue created, PR opened, @mention | Create PR, comment, push commits | +| **GitLab** | Issue created, MR opened | Create MR, comment, push commits | +| **Slack** | @mention in channel | Reply in thread, create tasks | +| **Jira** | Issue created/updated | Update ticket, add comments | +| **Linear** | Issue created | Update status, add comments | + +### Integration Components + +| Component | Purpose | Location | +|-----------|---------|----------| +| **Integration Routes** | Webhook endpoints per service | `enterprise/server/routes/integration/` | +| **Integration Managers** | Business logic per service | `enterprise/integrations/{service}/` | +| **Token Manager** | Store/retrieve OAuth tokens | `enterprise/server/auth/token_manager.py` | +| **Callback Processor** | Handle agent → service updates | `enterprise/integrations/{service}/*_callback_processor.py` | + +### Integration Authentication + +``` +External Service (e.g., GitHub) + │ + ▼ +┌─────────────────────────────────┐ +│ GitHub App Installation │ +│ - Webhook secret for signature │ +│ - App private key for API calls │ +└─────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ User Account Linking │ +│ - Keycloak user ID │ +│ - GitHub user ID │ +│ - Stored OAuth tokens │ +└─────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Agent Execution │ +│ - Uses linked tokens for API │ +│ - Can push, create PRs, comment │ +└─────────────────────────────────┘ +``` diff --git a/openhands/README.md b/openhands/README.md index 12c599fcd8..85eefc48c0 100644 --- a/openhands/README.md +++ b/openhands/README.md @@ -1,8 +1,14 @@ -# OpenHands Architecture This directory contains the core components of OpenHands. -For an overview of the system architecture, see the [architecture documentation](https://docs.openhands.dev/usage/architecture/backend) (v0 backend architecture). +## Documentation + +**[Architecture Documentation](./architecture/README.md)** with diagrams covering: + - System Architecture Overview + - Conversation Startup & WebSocket Flow + - Agent Execution & LLM Flow + +- **[External Architecture Docs](https://docs.openhands.dev/usage/architecture/backend)** - Official documentation (v0 backend architecture) ## Classes diff --git a/openhands/architecture/README.md b/openhands/architecture/README.md new file mode 100644 index 0000000000..095a34db23 --- /dev/null +++ b/openhands/architecture/README.md @@ -0,0 +1,10 @@ +# OpenHands Architecture + +Architecture diagrams and explanations for the OpenHands system. + +## Documentation Sections + +- [System Architecture Overview](./system-architecture.md) - Multi-tier architecture and component responsibilities +- [Conversation Startup & WebSocket Flow](./conversation-startup.md) - Runtime provisioning and real-time communication +- [Agent Execution & LLM Flow](./agent-execution.md) - LLM integration and action execution loop +- [Observability](./observability.md) - Logging, metrics, and monitoring diff --git a/openhands/architecture/agent-execution.md b/openhands/architecture/agent-execution.md new file mode 100644 index 0000000000..4d2df3c130 --- /dev/null +++ b/openhands/architecture/agent-execution.md @@ -0,0 +1,92 @@ +# Agent Execution & LLM Flow + +When the agent executes inside the sandbox, it makes LLM calls through LiteLLM: + +```mermaid +sequenceDiagram + autonumber + participant User as User (Browser) + participant AS as Agent Server + participant Agent as Agent
(CodeAct) + participant LLM as LLM Class + participant Lite as LiteLLM + participant Proxy as LLM Proxy
(llm-proxy.app.all-hands.dev) + participant Provider as LLM Provider
(OpenAI, Anthropic, etc.) + participant AES as Action Execution Server + + Note over User,AES: Agent Loop - LLM Call Flow + + User->>AS: WebSocket: User message + AS->>Agent: Process message + Note over Agent: Build prompt from state + + Agent->>LLM: completion(messages, tools) + Note over LLM: Apply config (model, temp, etc.) + + alt Using OpenHands Provider + LLM->>Lite: litellm_proxy/{model} + Lite->>Proxy: POST /chat/completions + Note over Proxy: Auth, rate limit, routing + Proxy->>Provider: Forward request + Provider-->>Proxy: Response + Proxy-->>Lite: Response + else Using Direct Provider + LLM->>Lite: {provider}/{model} + Lite->>Provider: Direct API call + Provider-->>Lite: Response + end + + Lite-->>LLM: ModelResponse + Note over LLM: Track metrics (cost, tokens) + LLM-->>Agent: Parsed response + + Note over Agent: Parse action from response + AS->>User: WebSocket: Action event + + Note over User,AES: Action Execution + + AS->>AES: HTTP: Execute action + Note over AES: Run command/edit file + AES-->>AS: Observation + AS->>User: WebSocket: Observation event + + Note over Agent: Update state + Note over Agent: Loop continues... +``` + +### LLM Components + +| Component | Purpose | Location | +|-----------|---------|----------| +| **LLM Class** | Wrapper with retries, metrics, config | `openhands/llm/llm.py` | +| **LiteLLM** | Universal LLM API adapter | External library | +| **LLM Proxy** | OpenHands managed proxy for billing/routing | `llm-proxy.app.all-hands.dev` | +| **LLM Registry** | Manages multiple LLM instances | `openhands/llm/llm_registry.py` | + +### Model Routing + +``` +User selects model + │ + ▼ +┌───────────────────┐ +│ Model prefix? │ +└───────────────────┘ + │ + ├── openhands/claude-3-5 ──► Rewrite to litellm_proxy/claude-3-5 + │ Base URL: llm-proxy.app.all-hands.dev + │ + ├── anthropic/claude-3-5 ──► Direct to Anthropic API + │ (User's API key) + │ + ├── openai/gpt-4 ──► Direct to OpenAI API + │ (User's API key) + │ + └── azure/gpt-4 ──► Direct to Azure OpenAI + (User's API key + endpoint) +``` + +### LLM Proxy + +When using `openhands/` prefixed models, requests are routed through a managed proxy. +See the [OpenHands documentation](https://docs.openhands.dev/) for details on supported models. diff --git a/openhands/architecture/conversation-startup.md b/openhands/architecture/conversation-startup.md new file mode 100644 index 0000000000..4da15aba1d --- /dev/null +++ b/openhands/architecture/conversation-startup.md @@ -0,0 +1,68 @@ +# Conversation Startup & WebSocket Flow + +When a user starts a conversation, this sequence occurs: + +```mermaid +sequenceDiagram + autonumber + participant User as User (Browser) + participant App as App Server + participant SS as Sandbox Service + participant RAPI as Runtime API + participant Pool as Warm Pool + participant Sandbox as Sandbox (Container) + participant AS as Agent Server + participant AES as Action Execution Server + + Note over User,AES: Phase 1: Conversation Creation + User->>App: POST /api/conversations + Note over App: Authenticate user + App->>SS: Create sandbox + + Note over SS,Pool: Phase 2: Runtime Provisioning + SS->>RAPI: POST /start (image, env, config) + RAPI->>Pool: Check for warm runtime + alt Warm runtime available + Pool-->>RAPI: Return warm runtime + Note over RAPI: Assign to session + else No warm runtime + RAPI->>Sandbox: Create new container + Sandbox->>AS: Start Agent Server + Sandbox->>AES: Start Action Execution Server + AES-->>AS: Ready + end + RAPI-->>SS: Runtime URL + session API key + SS-->>App: Sandbox info + App-->>User: Conversation ID + Sandbox URL + + Note over User,AES: Phase 3: Direct WebSocket Connection + User->>AS: WebSocket: /sockets/events/{id} + AS-->>User: Connection accepted + AS->>User: Replay historical events + + Note over User,AES: Phase 4: User Sends Message + User->>AS: WebSocket: SendMessageRequest + Note over AS: Agent processes message + Note over AS: LLM call → generate action + + Note over User,AES: Phase 5: Action Execution Loop + loop Agent Loop + AS->>AES: HTTP: Execute action + Note over AES: Run in sandbox + AES-->>AS: Observation result + AS->>User: WebSocket: Event update + Note over AS: Update state, next action + end + + Note over User,AES: Phase 6: Task Complete + AS->>User: WebSocket: AgentStateChanged (FINISHED) +``` + +### Key Points + +1. **Initial Setup via App Server**: The App Server handles authentication and coordinates with the Sandbox Service +2. **Runtime API Provisioning**: The Sandbox Service calls the Runtime API, which checks for warm runtimes before creating new containers +3. **Warm Pool Optimization**: Pre-warmed runtimes reduce startup latency significantly +4. **Direct WebSocket to Sandbox**: Once created, the user's browser connects **directly** to the Agent Server inside the sandbox +5. **App Server Not in Hot Path**: After connection, all real-time communication bypasses the App Server entirely +6. **Agent Server Orchestrates**: The Agent Server manages the AI loop, calling the Action Execution Server for actual command execution diff --git a/openhands/architecture/observability.md b/openhands/architecture/observability.md new file mode 100644 index 0000000000..d7c798c309 --- /dev/null +++ b/openhands/architecture/observability.md @@ -0,0 +1,85 @@ +# Observability + +OpenHands provides structured logging and metrics collection for monitoring and debugging. + +> **SDK Documentation**: For detailed guidance on observability and metrics in agent development, see: +> - [SDK Observability Guide](https://docs.openhands.dev/sdk/guides/observability) +> - [SDK Metrics Guide](https://docs.openhands.dev/sdk/guides/metrics) + +```mermaid +flowchart LR + subgraph Sources["Sources"] + Agent["Agent Server"] + App["App Server"] + Frontend["Frontend"] + end + + subgraph Collection["Collection"] + JSONLog["JSON Logs
(stdout)"] + Metrics["Metrics
(Internal)"] + end + + subgraph External["External (Optional)"] + LogAgg["Log Aggregator"] + Analytics["Analytics Service"] + end + + Agent --> JSONLog + App --> JSONLog + App --> Metrics + + JSONLog --> LogAgg + Frontend --> Analytics +``` + +### Structured Logging + +OpenHands uses Python's standard logging library with structured JSON output support. + +| Component | Format | Destination | Purpose | +|-----------|--------|-------------|---------| +| **Application Logs** | JSON (when `LOG_JSON=1`) | stdout | Debugging, error tracking | +| **Access Logs** | JSON (Uvicorn) | stdout | Request tracing | +| **LLM Debug Logs** | Plain text | File (optional) | LLM call debugging | + +### JSON Log Format + +When `LOG_JSON=1` is set, logs are emitted as single-line JSON for ingestion by log aggregators: + +```json +{ + "message": "Conversation started", + "severity": "INFO", + "conversation_id": "abc-123", + "user_id": "user-456", + "timestamp": "2024-01-15T10:30:00Z" +} +``` + +Additional context can be added using Python's logger `extra=` parameter (see [Python logging docs](https://docs.python.org/3/library/logging.html)). + +### Metrics + +| Metric | Tracked By | Storage | Purpose | +|--------|------------|---------|---------| +| **LLM Cost** | `Metrics` class | Conversation stats file | Billing, budget limits | +| **Token Usage** | `Metrics` class | Conversation stats file | Usage analytics | +| **Response Latency** | `Metrics` class | Conversation stats file | Performance monitoring | + +### Conversation Stats Persistence + +Per-conversation metrics are persisted for analytics: + +```python +# Location: openhands/server/services/conversation_stats.py +ConversationStats: + - service_to_metrics: Dict[str, Metrics] + - accumulated_cost: float + - token_usage: TokenUsage + +# Stored at: {file_store}/conversation_stats/{conversation_id}.pkl +``` + +### Integration with External Services + +Structured JSON logging allows integration with any log aggregation service (e.g., ELK Stack, Loki, Splunk). Configure your log collector to ingest from container stdout/stderr. diff --git a/openhands/architecture/system-architecture.md b/openhands/architecture/system-architecture.md new file mode 100644 index 0000000000..7745270b31 --- /dev/null +++ b/openhands/architecture/system-architecture.md @@ -0,0 +1,88 @@ +# System Architecture Overview + +OpenHands supports multiple deployment configurations. This document describes the core components and how they interact. + +## Local/Docker Deployment + +The simplest deployment runs everything locally or in Docker containers: + +```mermaid +flowchart TB + subgraph Server["OpenHands Server"] + API["REST API
(FastAPI)"] + ConvMgr["Conversation
Manager"] + Runtime["Runtime
Manager"] + end + + subgraph Sandbox["Sandbox (Docker Container)"] + AES["Action Execution
Server"] + Browser["Browser
Environment"] + FS["File System"] + end + + User["User"] -->|"HTTP/WebSocket"| API + API --> ConvMgr + ConvMgr --> Runtime + Runtime -->|"Provision"| Sandbox + + Server -->|"Execute actions"| AES + AES --> Browser + AES --> FS +``` + +### Core Components + +| Component | Purpose | Location | +|-----------|---------|----------| +| **Server** | REST API, conversation management, runtime orchestration | `openhands/server/` | +| **Runtime** | Abstract interface for sandbox execution | `openhands/runtime/` | +| **Action Execution Server** | Execute bash, file ops, browser actions | Inside sandbox | +| **EventStream** | Central event bus for all communication | `openhands/events/` | + +## Scalable Deployment + +For production deployments, OpenHands can be configured with a separate Runtime API service: + +```mermaid +flowchart TB + subgraph AppServer["App Server"] + API["REST API"] + ConvMgr["Conversation
Manager"] + end + + subgraph RuntimeAPI["Runtime API (Optional)"] + RuntimeMgr["Runtime
Manager"] + WarmPool["Warm Pool"] + end + + subgraph Sandbox["Sandbox"] + AS["Agent Server"] + AES["Action Execution
Server"] + end + + User["User"] -->|"HTTP"| API + API --> ConvMgr + ConvMgr -->|"Provision"| RuntimeMgr + RuntimeMgr --> WarmPool + RuntimeMgr --> Sandbox + + User -.->|"WebSocket"| AS + AS -->|"HTTP"| AES +``` + +This configuration enables: +- **Warm pool**: Pre-provisioned runtimes for faster startup +- **Direct WebSocket**: Users connect directly to their sandbox, bypassing the App Server +- **Horizontal scaling**: App Server and Runtime API can scale independently + +### Runtime Options + +OpenHands supports multiple runtime implementations: + +| Runtime | Use Case | +|---------|----------| +| **DockerRuntime** | Local development, single-machine deployments | +| **RemoteRuntime** | Connect to externally managed sandboxes | +| **ModalRuntime** | Serverless execution via Modal | + +See the [Runtime documentation](https://docs.openhands.dev/usage/architecture/runtime) for details. From 79cfffce60b0db5a4c4759ded9944a0825ca3839 Mon Sep 17 00:00:00 2001 From: Jamie Chicago <87397251+jamiechicago312@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:03:33 -0500 Subject: [PATCH 44/92] docs: Improve Development.md and CONTRIBUTING.md with OS-specific setup guides (#13432) Co-authored-by: enyst Co-authored-by: openhands --- AGENTS.md | 2 + CONTRIBUTING.md | 165 ++++++++++----------- Development.md | 382 ++++++++++++++++++++++++++++++++---------------- 3 files changed, 340 insertions(+), 209 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5bcf99cf8b..7a0bbc044a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ then re-run the command to ensure it passes. Common issues include: ## Repository Structure Backend: - Located in the `openhands` directory +- The current V1 application server lives in `openhands/app_server/`. `make start-backend` still launches `openhands.server.listen:app`, which includes the V1 routes by default unless `ENABLE_V1=0`. +- For V1 web-app docs, LLM setup should point users to the Settings UI. - Testing: - All tests are in `tests/unit/test_*.py` - To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1299f1a1e0..5d30510981 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,83 +1,105 @@ # Contributing -Thanks for your interest in contributing to OpenHands! We welcome and appreciate contributions. +Thanks for your interest in contributing to OpenHands! We're building the future of AI-powered software development, and we'd love for you to be part of this journey. -## Understanding OpenHands's CodeBase +## Our Vision -To understand the codebase, please refer to the README in each module: -- [frontend](./frontend/README.md) -- [openhands](./openhands/README.md) - - [agenthub](./openhands/agenthub/README.md) - - [server](./openhands/server/README.md) +The OpenHands community is built around the belief that AI and AI agents are going to fundamentally change the way we build software. If this is true, we should do everything we can to make sure that the benefits provided by such powerful technology are accessible to everyone. -For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository. +We believe in the power of open source to democratize access to cutting-edge AI technology. Just as the internet transformed how we share information, we envision a world where AI-powered development tools are available to every developer, regardless of their background or resources. -## Setting up Your Development Environment +## Getting Started -We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells -you how to set up a development workflow. +### Quick Ways to Contribute -## How Can I Contribute? +- **Use OpenHands** and [report issues](https://github.com/OpenHands/OpenHands/issues) you encounter +- **Give feedback** using the thumbs-up/thumbs-down buttons after each session +- **Star our repository** on [GitHub](https://github.com/OpenHands/OpenHands) +- **Share OpenHands** with other developers -There are many ways that you can contribute: +### Set Up Your Development Environment -1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see. -2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents. -3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on. +- **Requirements**: Linux/Mac/WSL, Docker, Python 3.12, Node.js 22+, Poetry 1.8+ +- **Quick setup**: `make build` +- **Run locally**: `make run` +- **LLM setup (V1 web app)**: configure your model and API key in the Settings UI after the app starts -## What Can I Build? +Full details in our [Development Guide](./Development.md). -Here are a few ways you can help improve the codebase. +### Find Your First Issue -#### UI/UX +- Browse [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) +- Check our [project boards](https://github.com/OpenHands/OpenHands/projects) for organized tasks +- Join our [Slack community](https://openhands.dev/joinslack) to ask what needs help -We're always looking to improve the look and feel of the application. If you've got a small fix -for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory. +## Understanding the Codebase -If you're looking to make a bigger change, add a new UI element, or significantly alter the style -of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack -to gather consensus from our design team first. +- **[Frontend](./frontend/README.md)** - React application +- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules +- **[Agents](./openhands/agenthub/README.md)** - AI agent implementations +- **[Runtime](./openhands/runtime/README.md)** - Execution environments +- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks -#### Improving the agent +## What Can You Build? -Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent). +### Frontend & UI/UX +- React & TypeScript development +- UI/UX improvements +- Mobile responsiveness +- Component libraries -Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience. -You can try modifying the prompts to see how they change the behavior of the agent as you use the app -locally, but we will need to do an end-to-end evaluation of any changes here to ensure that the agent -is getting better over time. +For bigger changes, join the #proj-gui channel in [Slack](https://openhands.dev/joinslack) first. -We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. You can join the #evaluation -channel in Slack to learn more. +### Agent Development +- Prompt engineering +- New agent types +- Agent evaluation +- Multi-agent systems -#### Adding a new agent +We use [SWE-bench](https://www.swebench.com/) to evaluate agents. -You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub) -to help expand the capabilities of OpenHands. +### Backend & Infrastructure +- Python development +- Runtime systems (Docker containers, sandboxes) +- Cloud integrations +- Performance optimization -#### Adding a new runtime +### Testing & Quality Assurance +- Unit testing +- Integration testing +- Bug hunting +- Performance testing -The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container -to do this by default. But there are other ways of creating a sandbox for the agent. +### Documentation & Education +- Technical documentation +- Translation +- Community support -If you work for a company that provides a cloud-based runtime, you could help us add support for that runtime -by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py). +## Pull Request Process -#### Testing +### Small Improvements +- Quick review and approval +- Ensure CI tests pass +- Include clear description of changes -When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing -test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e). -Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure -quality of the project. +### Core Agent Changes +These are evaluated based on: +- **Accuracy** - Does it make the agent better at solving problems? +- **Efficiency** - Does it improve speed or reduce resource usage? +- **Code Quality** - Is the code maintainable and well-tested? + +Discuss major changes in [GitHub issues](https://github.com/OpenHands/OpenHands/issues) or [Slack](https://openhands.dev/joinslack) first. ## Sending Pull Requests to OpenHands You'll need to fork our repository to send us a Pull Request. You can learn more about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8). -### Pull Request title +You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls). -As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes: +### Pull Request Title Format + +As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes: - `feat`: A new feature - `fix`: A bug fix @@ -95,45 +117,16 @@ For example, a PR title could be: - `refactor: modify package path` - `feat(frontend): xxxx`, where `(frontend)` means that this PR mainly focuses on the frontend component. -You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls). +### Pull Request Description -### Pull Request description +- Explain what the PR does and why +- Link to related issues +- Include screenshots for UI changes +- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix), + please include a short message that we can add to our changelog -- If your PR is small (such as a typo fix), you can go brief. -- If it contains a lot of changes, it's better to write more details. +## Need Help? -If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix) -please include a short message that we can add to our changelog. - -## How to Make Effective Contributions - -### Opening Issues - -If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage -based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that -the community has interest/effort for. - -Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize. - -### Making Pull Requests - -We're generally happy to consider all pull requests with the evaluation process varying based on the type of change: - -#### For Small Improvements - -Small improvements with few downsides are typically reviewed and approved quickly. -One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check -before getting a review. - -#### For Core Agent Changes - -We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are -evaluated based on three key metrics: - -1. **Accuracy** -2. **Efficiency** -3. **Code Complexity** - -If it improves accuracy, efficiency, or both with only a minimal change to code quality, that's great we're happy to merge it in! -If there are bigger tradeoffs (e.g. helping efficiency a lot and hurting accuracy a little) we might want to put it behind a feature flag. -Either way, please feel free to discuss on github issues or slack, and we will give guidance and preliminary feedback. +- **Slack**: [Join our community](https://openhands.dev/joinslack) +- **GitHub Issues**: [Open an issue](https://github.com/OpenHands/OpenHands/issues) +- **Email**: contact@openhands.dev diff --git a/Development.md b/Development.md index c4cbdf87e2..d4c1b345c6 100644 --- a/Development.md +++ b/Development.md @@ -6,22 +6,196 @@ If you wish to contribute your changes, check out the on how to clone and setup the project initially before moving on. Otherwise, you can clone the OpenHands project directly. -## Start the Server for Development +## Choose Your Setup -### 1. Requirements +Select your operating system to see the specific setup instructions: -- Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu >= 22.04] -- [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!) -- [Python](https://www.python.org/downloads/) = 3.12 -- [NodeJS](https://nodejs.org/en/download/package-manager) >= 22.x -- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8 -- OS-specific dependencies: - - Ubuntu: build-essential => `sudo apt-get install build-essential python3.12-dev` - - WSL: netcat => `sudo apt-get install netcat` +- [macOS](#macos-setup) +- [Linux](#linux-setup) +- [Windows WSL](#windows-wsl-setup) +- [Dev Container](#dev-container) +- [Developing in Docker](#developing-in-docker) +- [No sudo access?](#develop-without-sudo-access) -Make sure you have all these dependencies installed before moving on to `make build`. +--- -#### Dev container +## macOS Setup + +### 1. Install Prerequisites + +You'll need the following installed: + +- **Python 3.12** — `brew install python@3.12` (see the [official Homebrew Python docs](https://docs.brew.sh/Homebrew-and-Python) for details). Make sure `python3.12` is available in your PATH (the `make build` step will verify this). +- **Node.js >= 22** — `brew install node` +- **Poetry >= 1.8** — `brew install poetry` +- **Docker Desktop** — `brew install --cask docker` + - After installing, open Docker Desktop → **Settings → Advanced** → Enable **"Allow the default Docker socket to be used"** + +### 2. Build and Setup the Environment + +```bash +make build +``` + +### 3. Configure the Language Model + +OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library. + +For the V1 web app, start OpenHands and configure your model and API key in the Settings UI. + +If you are running headless or CLI workflows, you can prepare local defaults with: + +```bash +make setup-config +``` + +**Note on Alternative Models:** +See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models. + +### 4. Run the Application + +```bash +# Run both backend and frontend +make run + +# Or run separately: +make start-backend # Backend only on port 3000 +make start-frontend # Frontend only on port 3001 +``` + +These targets serve the current OpenHands V1 API by default. In the codebase, `make start-backend` runs `openhands.server.listen:app`, and that app includes the `openhands/app_server` V1 routes unless `ENABLE_V1=0`. + +--- + +## Linux Setup + +This guide covers Ubuntu/Debian. For other distributions, adapt the package manager commands accordingly. + +### 1. Install Prerequisites + +```bash +# Update package list +sudo apt update + +# Install system dependencies +sudo apt install -y build-essential curl netcat software-properties-common + +# Install Python 3.12 +# Ubuntu 24.04+ and Debian 13+ ship with Python 3.12 — skip the PPA step if +# python3.12 --version already works on your system. +# The deadsnakes PPA is Ubuntu-only and needed for Ubuntu 22.04 or older: +sudo add-apt-repository -y ppa:deadsnakes/ppa +sudo apt update +sudo apt install -y python3.12 python3.12-dev python3.12-venv + +# Install Node.js 22.x +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install -y nodejs + +# Install Poetry +curl -sSL https://install.python-poetry.org | python3 - + +# Add Poetry to your PATH +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc + +# Install Docker +# Follow the official guide: https://docs.docker.com/engine/install/ubuntu/ +# Quick version: +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc +sudo chmod a+r /etc/apt/keyrings/docker.asc +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +sudo apt update +sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +sudo usermod -aG docker $USER +# Log out and back in for Docker group changes to take effect +``` + +### 2. Build and Setup the Environment + +```bash +make build +``` + +### 3. Configure the Language Model + +See the [macOS section above](#3-configure-the-language-model) for guidance: configure your model and API key in the Settings UI. + +### 4. Run the Application + +```bash +# Run both backend and frontend +make run + +# Or run separately: +make start-backend # Backend only on port 3000 +make start-frontend # Frontend only on port 3001 +``` + +--- + +## Windows WSL Setup + +WSL2 with Ubuntu is recommended. The setup is similar to Linux, with a few WSL-specific considerations. + +### 1. Install WSL2 + +**Option A: Windows 11 (Microsoft Store)** +The easiest way on Windows 11: +1. Open the **Microsoft Store** app +2. Search for **"Ubuntu 22.04 LTS"** or **"Ubuntu"** +3. Click **Install** +4. Launch Ubuntu from the Start menu + +**Option B: PowerShell** +```powershell +# Run this in PowerShell as Administrator +wsl --install -d Ubuntu-22.04 +``` + +After installation, restart your computer and open Ubuntu. + +### 2. Install Prerequisites (in WSL Ubuntu) + +Follow [Step 1 from the Linux setup](#1-install-prerequisites-1) to install system dependencies, Python 3.12, Node.js, and Poetry. Skip the Docker installation — Docker is provided through Docker Desktop below. + +### 3. Configure Docker for WSL2 + +1. Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop) +2. Open Docker Desktop > Settings > General +3. Enable: "Use the WSL 2 based engine" +4. Go to Settings > Resources > WSL Integration +5. Enable integration with your Ubuntu distribution + +**Important:** Keep your project files in the WSL filesystem (e.g., `~/workspace/openhands`), not in `/mnt/c`. Files accessed via `/mnt/c` will be significantly slower. + +### 4. Build and Setup the Environment + +```bash +make build +``` + +### 5. Configure the Language Model + +See the [macOS section above](#3-configure-the-language-model) for the current V1 guidance: configure your model and API key in the Settings UI for the web app, and use `make setup-config` only for headless or CLI workflows. + +### 6. Run the Application + +```bash +# Run both backend and frontend +make run + +# Or run separately: +make start-backend # Backend only on port 3000 +make start-frontend # Frontend only on port 3001 +``` + +Access the frontend at `http://localhost:3001` from your Windows browser. + +--- + +## Dev Container There is a [dev container](https://containers.dev/) available which provides a pre-configured environment with all the necessary dependencies installed if you @@ -32,7 +206,38 @@ extension installed, you can open the project in a dev container by using the _Dev Container: Reopen in Container_ command from the Command Palette (Ctrl+Shift+P). -#### Develop without sudo access +--- + +## Developing in Docker + +If you don't want to install dependencies on your host machine, you can develop inside a Docker container. + +### Quick Start + +```bash +make docker-dev +``` + +For more details, see the [dev container documentation](./containers/dev/README.md). + +### Alternative: Docker Run + +If you just want to run OpenHands without setting up a dev environment: + +```bash +make docker-run +``` + +If you don't have `make` installed, run: + +```bash +cd ./containers/dev +./dev.sh +``` + +--- + +## Develop without sudo access If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use `conda` or `mamba` to manage the packages for you: @@ -48,159 +253,90 @@ mamba install conda-forge::nodejs mamba install conda-forge::poetry ``` -### 2. Build and Setup The Environment +--- -Begin by building the project which includes setting up the environment and installing dependencies. This step ensures -that OpenHands is ready to run on your system: +## Running OpenHands with OpenHands + +You can use OpenHands to develop and improve OpenHands itself! + +### Quick Start ```bash -make build +export INSTALL_DOCKER=0 +export RUNTIME=local +make build && make run ``` -### 3. Configuring the Language Model +Access the interface at: +- Local development: http://localhost:3001 +- Remote/cloud environments: Use the appropriate external URL -OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library. +For external access: +```bash +make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 +``` -To configure the LM of your choice, run: +--- + +## LLM Debugging + +If you encounter issues with the Language Model, enable debug logging: ```bash -make setup-config +export DEBUG=1 +# Restart the backend +make start-backend ``` -This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is -tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI, -please set the model in the UI. +Logs will be saved to `logs/llm/CURRENT_DATE/` for troubleshooting. -Note: If you have previously run OpenHands using the docker command, you may have already set some environment -variables in your terminal. The final configurations are set from highest to lowest priority: -Environment variables > config.toml variables > default variables +--- -**Note on Alternative Models:** -See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models. +## Testing -### 4. Running the application - -#### Option A: Run the Full Application - -Once the setup is complete, this command starts both the backend and frontend servers, allowing you to interact with OpenHands: - -```bash -make run -``` - -#### Option B: Individual Server Startup - -- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on - backend-related tasks or configurations. - - ```bash - make start-backend - ``` - -- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related - components or interface enhancements. - ```bash - make start-frontend - ``` - -### 5. Running OpenHands with OpenHands - -You can use OpenHands to develop and improve OpenHands itself! This is a powerful way to leverage AI assistance for contributing to the project. - -#### Quick Start - -1. **Build and run OpenHands:** - - ```bash - export INSTALL_DOCKER=0 - export RUNTIME=local - make build && make run - ``` - -2. **Access the interface:** - - - Local development: http://localhost:3001 - - Remote/cloud environments: Use the appropriate external URL - -3. **Configure for external access (if needed):** - ```bash - # For external access (e.g., cloud environments) - make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 - ``` - -### 6. LLM Debugging - -If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend. -OpenHands will log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes. - -### 7. Help - -Need help or info on available targets and commands? Use the help command for all the guidance you need with OpenHands. - -```bash -make help -``` - -### 8. Testing - -To run tests, refer to the following: - -#### Unit tests +### Unit Tests ```bash poetry run pytest ./tests/unit/test_*.py ``` -### 9. Add or update dependency +--- -1. Add your dependency in `pyproject.toml` or use `poetry add xxx`. -2. Update the poetry.lock file via `poetry lock --no-update`. +## Adding Dependencies -### 10. Use existing Docker image +1. Add your dependency in `pyproject.toml` or use `poetry add xxx` +2. Update the lock file: `poetry lock --no-update` -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:1.2-nikolaik` +## Using Existing Docker Images -## Develop inside Docker container - -TL;DR +To reduce build time, you can use an existing runtime image: ```bash -make docker-dev +export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik ``` -See more details [here](./containers/dev/README.md). +--- -If you are just interested in running `OpenHands` without installing all the required tools on your host. +## Help ```bash -make docker-run +make help ``` -If you do not have `make` on your host, run: - -```bash -cd ./containers/dev -./dev.sh -``` - -You do need [Docker](https://docs.docker.com/engine/install/) installed on your host though. +--- ## Key Documentation Resources -Here's a guide to the important documentation files in the repository: - - [/README.md](./README.md): Main project overview, features, and basic setup instructions - [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands - [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process - [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation -- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation +- [/openhands/app_server/README.md](./openhands/app_server/README.md): Current V1 application server implementation and REST API modules - [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide - [/containers/README.md](./containers/README.md): Information about Docker containers and deployment - [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests - [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks - [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation -- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation - [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model From 09ca1b882f348ba3122fc648f568239929878437 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Tue, 17 Mar 2026 14:48:46 -0400 Subject: [PATCH 45/92] (Hotfix): use direct attrib for file download result (#13448) --- .../app_conversation/app_conversation_service_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands/app_server/app_conversation/app_conversation_service_base.py b/openhands/app_server/app_conversation/app_conversation_service_base.py index 97cfa30087..60a2036ce6 100644 --- a/openhands/app_server/app_conversation/app_conversation_service_base.py +++ b/openhands/app_server/app_conversation/app_conversation_service_base.py @@ -405,7 +405,7 @@ class AppConversationServiceBase(AppConversationService, ABC): # Check if there's an existing pre-commit hook with tempfile.TemporaryFile(mode='w+t') as temp_file: result = await workspace.file_download(PRE_COMMIT_HOOK, str(temp_file)) - if result.get('success'): + if result.success: _logger.info('Preserving existing pre-commit hook') # an existing pre-commit hook exists if 'This hook was installed by OpenHands' not in temp_file.read(): From 855ef7ba5f417fd9cffa09beba178bbfb3689251 Mon Sep 17 00:00:00 2001 From: aivong-openhands Date: Tue, 17 Mar 2026 14:26:13 -0500 Subject: [PATCH 46/92] PLTF-309: disable budget enforcement when ENABLE_BILLING=false (#13440) Co-authored-by: openhands --- enterprise/storage/lite_llm_manager.py | 83 +++++++++++++++---- .../tests/unit/test_lite_llm_manager.py | 39 +++++++-- 2 files changed, 101 insertions(+), 21 deletions(-) diff --git a/enterprise/storage/lite_llm_manager.py b/enterprise/storage/lite_llm_manager.py index 836ebe8278..725b8147a3 100644 --- a/enterprise/storage/lite_llm_manager.py +++ b/enterprise/storage/lite_llm_manager.py @@ -29,14 +29,37 @@ KEY_VERIFICATION_TIMEOUT = 5.0 # A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug. UNLIMITED_BUDGET_SETTING = 1000000000.0 -try: - DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0)) - if DEFAULT_INITIAL_BUDGET < 0: +# Check if billing is enabled (defaults to false for enterprise deployments) +ENABLE_BILLING = os.environ.get('ENABLE_BILLING', 'false').lower() == 'true' + + +def _get_default_initial_budget() -> float | None: + """Get the default initial budget for new teams. + + When billing is disabled (ENABLE_BILLING=false), returns None to disable + budget enforcement in LiteLLM. When billing is enabled, returns the + DEFAULT_INITIAL_BUDGET environment variable value (default 0.0). + + Returns: + float | None: The default budget, or None to disable budget enforcement. + """ + if not ENABLE_BILLING: + return None + + try: + budget = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0)) + if budget < 0: + raise ValueError( + f'DEFAULT_INITIAL_BUDGET must be non-negative, got {budget}' + ) + return budget + except ValueError as e: raise ValueError( - f'DEFAULT_INITIAL_BUDGET must be non-negative, got {DEFAULT_INITIAL_BUDGET}' - ) -except ValueError as e: - raise ValueError(f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}') from e + f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}' + ) from e + + +DEFAULT_INITIAL_BUDGET: float | None = _get_default_initial_budget() def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str: @@ -110,12 +133,15 @@ class LiteLlmManager: ) as client: # Check if team already exists and get its budget # New users joining existing orgs should inherit the team's budget - team_budget: float = DEFAULT_INITIAL_BUDGET + # When billing is disabled, DEFAULT_INITIAL_BUDGET is None + team_budget: float | None = DEFAULT_INITIAL_BUDGET try: existing_team = await LiteLlmManager._get_team(client, org_id) if existing_team: team_info = existing_team.get('team_info', {}) - team_budget = team_info.get('max_budget', 0.0) or 0.0 + # Preserve None from existing team (no budget enforcement) + existing_budget = team_info.get('max_budget') + team_budget = existing_budget logger.info( 'LiteLlmManager:create_entries:existing_team_budget', extra={ @@ -525,8 +551,17 @@ class LiteLlmManager: client: httpx.AsyncClient, team_alias: str, team_id: str, - max_budget: float, + max_budget: float | None, ): + """Create a new team in LiteLLM. + + Args: + client: The HTTP client to use. + team_alias: The alias for the team. + team_id: The ID for the team. + max_budget: The maximum budget for the team. When None, budget + enforcement is disabled (unlimited usage). + """ if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None: logger.warning('LiteLLM API configuration not found') return @@ -536,7 +571,7 @@ class LiteLlmManager: 'team_id': team_id, 'team_alias': team_alias, 'models': [], - 'max_budget': max_budget, + 'max_budget': max_budget, # None disables budget enforcement 'spend': 0, 'metadata': { 'version': ORG_SETTINGS_VERSION, @@ -918,8 +953,17 @@ class LiteLlmManager: client: httpx.AsyncClient, keycloak_user_id: str, team_id: str, - max_budget: float, + max_budget: float | None, ): + """Add a user to a team in LiteLLM. + + Args: + client: The HTTP client to use. + keycloak_user_id: The user's Keycloak ID. + team_id: The team ID. + max_budget: The maximum budget for the user in the team. When None, + budget enforcement is disabled (unlimited usage). + """ if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None: logger.warning('LiteLLM API configuration not found') return @@ -928,7 +972,7 @@ class LiteLlmManager: json={ 'team_id': team_id, 'member': {'user_id': keycloak_user_id, 'role': 'user'}, - 'max_budget_in_team': max_budget, + 'max_budget_in_team': max_budget, # None disables budget enforcement }, ) # Failed to add user to team - this is an unforseen error state... @@ -998,8 +1042,17 @@ class LiteLlmManager: client: httpx.AsyncClient, keycloak_user_id: str, team_id: str, - max_budget: float, + max_budget: float | None, ): + """Update a user's budget in a team. + + Args: + client: The HTTP client to use. + keycloak_user_id: The user's Keycloak ID. + team_id: The team ID. + max_budget: The maximum budget for the user in the team. When None, + budget enforcement is disabled (unlimited usage). + """ if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None: logger.warning('LiteLLM API configuration not found') return @@ -1008,7 +1061,7 @@ class LiteLlmManager: json={ 'team_id': team_id, 'user_id': keycloak_user_id, - 'max_budget_in_team': max_budget, + 'max_budget_in_team': max_budget, # None disables budget enforcement }, ) # Failed to update user in team - this is an unforseen error state... diff --git a/enterprise/tests/unit/test_lite_llm_manager.py b/enterprise/tests/unit/test_lite_llm_manager.py index 1f7623d79c..0cfc9fe58b 100644 --- a/enterprise/tests/unit/test_lite_llm_manager.py +++ b/enterprise/tests/unit/test_lite_llm_manager.py @@ -38,8 +38,9 @@ class TestDefaultInitialBudget: if 'storage.lite_llm_manager' in sys.modules: del sys.modules['storage.lite_llm_manager'] - # Clear the env var + # Clear the env vars os.environ.pop('DEFAULT_INITIAL_BUDGET', None) + os.environ.pop('ENABLE_BILLING', None) # Restore original module or reimport fresh if original_module is not None: @@ -47,31 +48,56 @@ class TestDefaultInitialBudget: else: importlib.import_module('storage.lite_llm_manager') - def test_default_initial_budget_defaults_to_zero(self): - """Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when env var not set.""" + def test_default_initial_budget_none_when_billing_disabled(self): + """Test that DEFAULT_INITIAL_BUDGET is None when billing is disabled.""" # Temporarily remove the module so we can reimport with different env vars if 'storage.lite_llm_manager' in sys.modules: del sys.modules['storage.lite_llm_manager'] - # Clear the env var and reimport + # Ensure billing is disabled (default) and reimport + os.environ.pop('ENABLE_BILLING', None) + os.environ.pop('DEFAULT_INITIAL_BUDGET', None) + module = importlib.import_module('storage.lite_llm_manager') + assert module.DEFAULT_INITIAL_BUDGET is None + + def test_default_initial_budget_defaults_to_zero_when_billing_enabled(self): + """Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when billing is enabled.""" + # Temporarily remove the module so we can reimport with different env vars + if 'storage.lite_llm_manager' in sys.modules: + del sys.modules['storage.lite_llm_manager'] + + # Enable billing and reimport + os.environ['ENABLE_BILLING'] = 'true' os.environ.pop('DEFAULT_INITIAL_BUDGET', None) module = importlib.import_module('storage.lite_llm_manager') assert module.DEFAULT_INITIAL_BUDGET == 0.0 - def test_default_initial_budget_uses_env_var(self): - """Test that DEFAULT_INITIAL_BUDGET uses value from environment variable.""" + def test_default_initial_budget_uses_env_var_when_billing_enabled(self): + """Test that DEFAULT_INITIAL_BUDGET uses value from environment variable when billing enabled.""" if 'storage.lite_llm_manager' in sys.modules: del sys.modules['storage.lite_llm_manager'] + os.environ['ENABLE_BILLING'] = 'true' os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0' module = importlib.import_module('storage.lite_llm_manager') assert module.DEFAULT_INITIAL_BUDGET == 100.0 + def test_default_initial_budget_ignores_env_var_when_billing_disabled(self): + """Test that DEFAULT_INITIAL_BUDGET returns None when billing disabled, ignoring env var.""" + if 'storage.lite_llm_manager' in sys.modules: + del sys.modules['storage.lite_llm_manager'] + + os.environ.pop('ENABLE_BILLING', None) # billing disabled by default + os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0' + module = importlib.import_module('storage.lite_llm_manager') + assert module.DEFAULT_INITIAL_BUDGET is None + def test_default_initial_budget_rejects_invalid_value(self): """Test that DEFAULT_INITIAL_BUDGET raises ValueError for invalid values.""" if 'storage.lite_llm_manager' in sys.modules: del sys.modules['storage.lite_llm_manager'] + os.environ['ENABLE_BILLING'] = 'true' os.environ['DEFAULT_INITIAL_BUDGET'] = 'abc' with pytest.raises(ValueError) as exc_info: importlib.import_module('storage.lite_llm_manager') @@ -82,6 +108,7 @@ class TestDefaultInitialBudget: if 'storage.lite_llm_manager' in sys.modules: del sys.modules['storage.lite_llm_manager'] + os.environ['ENABLE_BILLING'] = 'true' os.environ['DEFAULT_INITIAL_BUDGET'] = '-10.0' with pytest.raises(ValueError) as exc_info: importlib.import_module('storage.lite_llm_manager') From 7516b53f5a3bceed94f6a4776dd426df396e67e6 Mon Sep 17 00:00:00 2001 From: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:51:40 -0700 Subject: [PATCH 47/92] feat(frontend): self hosted new user questions (#13367) Co-authored-by: openhands Co-authored-by: hieptl --- .../onboarding/onboarding-form.test.tsx | 152 +++++-- .../features/onboarding/step-content.test.tsx | 4 +- .../features/onboarding/step-input.test.tsx | 72 ++++ .../features/onboarding/step-content.tsx | 31 +- .../features/onboarding/step-header.tsx | 13 +- .../features/onboarding/step-input.tsx | 27 ++ frontend/src/constants/onboarding.tsx | 101 +++++ .../hooks/mutation/use-submit-onboarding.ts | 2 +- frontend/src/hooks/query/use-config.ts | 8 +- frontend/src/hooks/use-tracking.ts | 6 +- frontend/src/i18n/declaration.ts | 47 ++- frontend/src/i18n/translation.json | 384 ++++++++++-------- frontend/src/routes/onboarding-form.tsx | 259 ++++++------ 13 files changed, 744 insertions(+), 362 deletions(-) create mode 100644 frontend/__tests__/components/features/onboarding/step-input.test.tsx create mode 100644 frontend/src/components/features/onboarding/step-input.tsx create mode 100644 frontend/src/constants/onboarding.tsx diff --git a/frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx b/frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx index f4735aedc3..489fc692ef 100644 --- a/frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx +++ b/frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx @@ -7,6 +7,8 @@ import OnboardingForm from "#/routes/onboarding-form"; const mockMutate = vi.fn(); const mockNavigate = vi.fn(); +const mockUseConfig = vi.fn(); +const mockTrackOnboardingCompleted = vi.fn(); vi.mock("react-router", async (importOriginal) => { const original = await importOriginal(); @@ -22,6 +24,16 @@ vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({ }), })); +vi.mock("#/hooks/query/use-config", () => ({ + useConfig: () => mockUseConfig(), +})); + +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackOnboardingCompleted: mockTrackOnboardingCompleted, + }), +})); + const renderOnboardingForm = () => { return renderWithProviders( @@ -30,10 +42,15 @@ const renderOnboardingForm = () => { ); }; -describe("OnboardingForm", () => { +describe("OnboardingForm - SaaS Mode", () => { beforeEach(() => { mockMutate.mockClear(); mockNavigate.mockClear(); + mockTrackOnboardingCompleted.mockClear(); + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, + }); }); it("should render with the correct test id", () => { @@ -50,7 +67,7 @@ describe("OnboardingForm", () => { expect(screen.getByTestId("step-actions")).toBeInTheDocument(); }); - it("should display step progress indicator with 3 bars", () => { + it("should display step progress indicator with 3 bars for saas mode", () => { renderOnboardingForm(); const stepHeader = screen.getByTestId("step-header"); @@ -69,7 +86,7 @@ describe("OnboardingForm", () => { const user = userEvent.setup(); renderOnboardingForm(); - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); const nextButton = screen.getByRole("button", { name: /next/i }); expect(nextButton).not.toBeDisabled(); @@ -84,7 +101,7 @@ describe("OnboardingForm", () => { let progressBars = stepHeader.querySelectorAll(".bg-white"); expect(progressBars).toHaveLength(1); - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); // On step 2, first two progress bars should be filled @@ -96,7 +113,7 @@ describe("OnboardingForm", () => { const user = userEvent.setup(); renderOnboardingForm(); - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); const nextButton = screen.getByRole("button", { name: /next/i }); @@ -107,29 +124,51 @@ describe("OnboardingForm", () => { const user = userEvent.setup(); renderOnboardingForm(); - // Step 1 - select role - await user.click(screen.getByTestId("step-option-software_engineer")); - await user.click(screen.getByRole("button", { name: /next/i })); - - // Step 2 - select org size + // Step 1 - select org size (first step in saas mode - single select) await user.click(screen.getByTestId("step-option-org_2_10")); await user.click(screen.getByRole("button", { name: /next/i })); - // Step 3 - select use case + // Step 2 - select use case (multi-select) await user.click(screen.getByTestId("step-option-new_features")); + await user.click(screen.getByRole("button", { name: /next/i })); + + // Step 3 - select role (last step in saas mode - single select) + await user.click(screen.getByTestId("step-option-software_engineer")); await user.click(screen.getByRole("button", { name: /finish/i })); expect(mockMutate).toHaveBeenCalledTimes(1); expect(mockMutate).toHaveBeenCalledWith({ selections: { - step1: "software_engineer", - step2: "org_2_10", - step3: "new_features", + org_size: "org_2_10", + use_case: ["new_features"], + role: "software_engineer", }, }); }); - it("should render 6 options on step 1", () => { + it("should track onboarding completion to PostHog in SaaS mode", async () => { + const user = userEvent.setup(); + renderOnboardingForm(); + + // Complete the full SaaS onboarding flow + await user.click(screen.getByTestId("step-option-org_2_10")); + await user.click(screen.getByRole("button", { name: /next/i })); + + await user.click(screen.getByTestId("step-option-new_features")); + await user.click(screen.getByRole("button", { name: /next/i })); + + await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByRole("button", { name: /finish/i })); + + expect(mockTrackOnboardingCompleted).toHaveBeenCalledTimes(1); + expect(mockTrackOnboardingCompleted).toHaveBeenCalledWith({ + role: "software_engineer", + orgSize: "org_2_10", + useCase: ["new_features"], + }); + }); + + it("should render 5 options on step 1 (org size question)", () => { renderOnboardingForm(); const options = screen @@ -137,31 +176,86 @@ describe("OnboardingForm", () => { .filter((btn) => btn.getAttribute("data-testid")?.startsWith("step-option-"), ); - expect(options).toHaveLength(6); + expect(options).toHaveLength(5); }); it("should preserve selections when navigating through steps", async () => { const user = userEvent.setup(); renderOnboardingForm(); - // Select role on step 1 - await user.click(screen.getByTestId("step-option-cto_founder")); - await user.click(screen.getByRole("button", { name: /next/i })); - - // Select org size on step 2 + // Select org size on step 1 (single select) await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); - // Select use case on step 3 + // Select use case on step 2 (multi-select) await user.click(screen.getByTestId("step-option-fixing_bugs")); + await user.click(screen.getByRole("button", { name: /next/i })); + + // Select role on step 3 (single select) + await user.click(screen.getByTestId("step-option-cto_founder")); await user.click(screen.getByRole("button", { name: /finish/i })); // Verify all selections were preserved expect(mockMutate).toHaveBeenCalledWith({ selections: { - step1: "cto_founder", - step2: "solo", - step3: "fixing_bugs", + org_size: "solo", + use_case: ["fixing_bugs"], + role: "cto_founder", + }, + }); + }); + + it("should allow selecting multiple options on multi-select steps", async () => { + const user = userEvent.setup(); + renderOnboardingForm(); + + // Step 1 - select org size (single select) + await user.click(screen.getByTestId("step-option-solo")); + await user.click(screen.getByRole("button", { name: /next/i })); + + // Step 2 - select multiple use cases (multi-select) + await user.click(screen.getByTestId("step-option-new_features")); + await user.click(screen.getByTestId("step-option-fixing_bugs")); + await user.click(screen.getByTestId("step-option-refactoring")); + await user.click(screen.getByRole("button", { name: /next/i })); + + // Step 3 - select role (single select) + await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByRole("button", { name: /finish/i })); + + expect(mockMutate).toHaveBeenCalledWith({ + selections: { + org_size: "solo", + use_case: ["new_features", "fixing_bugs", "refactoring"], + role: "software_engineer", + }, + }); + }); + + it("should allow deselecting options on multi-select steps", async () => { + const user = userEvent.setup(); + renderOnboardingForm(); + + // Step 1 - select org size + await user.click(screen.getByTestId("step-option-solo")); + await user.click(screen.getByRole("button", { name: /next/i })); + + // Step 2 - select and deselect use cases + await user.click(screen.getByTestId("step-option-new_features")); + await user.click(screen.getByTestId("step-option-fixing_bugs")); + await user.click(screen.getByTestId("step-option-new_features")); // Deselect + + await user.click(screen.getByRole("button", { name: /next/i })); + + // Step 3 - select role + await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByRole("button", { name: /finish/i })); + + expect(mockMutate).toHaveBeenCalledWith({ + selections: { + org_size: "solo", + use_case: ["fixing_bugs"], + role: "software_engineer", }, }); }); @@ -171,10 +265,10 @@ describe("OnboardingForm", () => { renderOnboardingForm(); // Navigate to step 3 - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); - await user.click(screen.getByTestId("step-option-solo")); + await user.click(screen.getByTestId("step-option-new_features")); await user.click(screen.getByRole("button", { name: /next/i })); // On step 3, all three progress bars should be filled @@ -194,7 +288,7 @@ describe("OnboardingForm", () => { const user = userEvent.setup(); renderOnboardingForm(); - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); const backButton = screen.getByRole("button", { name: /back/i }); @@ -206,7 +300,7 @@ describe("OnboardingForm", () => { renderOnboardingForm(); // Navigate to step 2 - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); // Verify we're on step 2 (2 progress bars filled) diff --git a/frontend/__tests__/components/features/onboarding/step-content.test.tsx b/frontend/__tests__/components/features/onboarding/step-content.test.tsx index e8ac37eda4..8f0803cb7c 100644 --- a/frontend/__tests__/components/features/onboarding/step-content.test.tsx +++ b/frontend/__tests__/components/features/onboarding/step-content.test.tsx @@ -12,7 +12,7 @@ describe("StepContent", () => { const defaultProps = { options: mockOptions, - selectedOptionId: null, + selectedOptionIds: [], onSelectOption: vi.fn(), }; @@ -44,7 +44,7 @@ describe("StepContent", () => { }); it("should mark the selected option as selected", () => { - render(); + render(); const selectedOption = screen.getByTestId("step-option-option1"); const unselectedOption = screen.getByTestId("step-option-option2"); diff --git a/frontend/__tests__/components/features/onboarding/step-input.test.tsx b/frontend/__tests__/components/features/onboarding/step-input.test.tsx new file mode 100644 index 0000000000..a4f388efbb --- /dev/null +++ b/frontend/__tests__/components/features/onboarding/step-input.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { StepInput } from "#/components/features/onboarding/step-input"; + +describe("StepInput", () => { + const defaultProps = { + id: "test-input", + label: "Test Label", + value: "", + onChange: vi.fn(), + }; + + it("should render with correct test id", () => { + render(); + + expect(screen.getByTestId("step-input-test-input")).toBeInTheDocument(); + }); + + it("should render the label", () => { + render(); + + expect(screen.getByText("Test Label")).toBeInTheDocument(); + }); + + it("should display the provided value", () => { + render(); + + const input = screen.getByTestId("step-input-test-input"); + expect(input).toHaveValue("Hello World"); + }); + + it("should call onChange when user types", async () => { + const mockOnChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const input = screen.getByTestId("step-input-test-input"); + await user.type(input, "a"); + + expect(mockOnChange).toHaveBeenCalledWith("a"); + }); + + it("should call onChange with the full input value on each keystroke", async () => { + const mockOnChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const input = screen.getByTestId("step-input-test-input"); + await user.type(input, "abc"); + + expect(mockOnChange).toHaveBeenCalledTimes(3); + expect(mockOnChange).toHaveBeenNthCalledWith(1, "a"); + expect(mockOnChange).toHaveBeenNthCalledWith(2, "b"); + expect(mockOnChange).toHaveBeenNthCalledWith(3, "c"); + }); + + it("should use the id prop for data-testid", () => { + render(); + + expect(screen.getByTestId("step-input-org_name")).toBeInTheDocument(); + }); + + it("should render as a text input", () => { + render(); + + const input = screen.getByTestId("step-input-test-input"); + expect(input).toHaveAttribute("type", "text"); + }); +}); diff --git a/frontend/src/components/features/onboarding/step-content.tsx b/frontend/src/components/features/onboarding/step-content.tsx index 658b8f63c0..3f99e54389 100644 --- a/frontend/src/components/features/onboarding/step-content.tsx +++ b/frontend/src/components/features/onboarding/step-content.tsx @@ -1,35 +1,56 @@ import { StepOption } from "./step-option"; +import { StepInput } from "./step-input"; export interface Option { id: string; label: string; } +export interface InputField { + id: string; + label: string; +} + interface StepContentProps { - options: Option[]; - selectedOptionId: string | null; + options?: Option[]; + inputFields?: InputField[]; + selectedOptionIds: string[]; + inputValues?: Record; onSelectOption: (optionId: string) => void; + onInputChange?: (fieldId: string, value: string) => void; } export function StepContent({ options, - selectedOptionId, + inputFields, + selectedOptionIds, + inputValues = {}, onSelectOption, + onInputChange, }: StepContentProps) { return (
- {options.map((option) => ( + {options?.map((option) => ( onSelectOption(option.id)} /> ))} + {inputFields?.map((field) => ( + onInputChange?.(field.id, value)} + /> + ))}
); } diff --git a/frontend/src/components/features/onboarding/step-header.tsx b/frontend/src/components/features/onboarding/step-header.tsx index bf1e458ec7..a0aa4044b2 100644 --- a/frontend/src/components/features/onboarding/step-header.tsx +++ b/frontend/src/components/features/onboarding/step-header.tsx @@ -3,11 +3,17 @@ import { cn } from "#/utils/utils"; interface StepHeaderProps { title: string; + subtitle?: string; currentStep: number; totalSteps: number; } -function StepHeader({ title, currentStep, totalSteps }: StepHeaderProps) { +function StepHeader({ + title, + subtitle, + currentStep, + totalSteps, +}: StepHeaderProps) { return (
@@ -24,6 +30,11 @@ function StepHeader({ title, currentStep, totalSteps }: StepHeaderProps) { {title} + {subtitle && ( + + {subtitle} + + )}
); } diff --git a/frontend/src/components/features/onboarding/step-input.tsx b/frontend/src/components/features/onboarding/step-input.tsx new file mode 100644 index 0000000000..4364a4f6b0 --- /dev/null +++ b/frontend/src/components/features/onboarding/step-input.tsx @@ -0,0 +1,27 @@ +interface StepInputProps { + id: string; + label: string; + value: string; + onChange: (value: string) => void; +} + +export function StepInput({ id, label, value, onChange }: StepInputProps) { + return ( +
+ + onChange(e.target.value)} + className="w-full rounded-md border border-[#3a3a3a] bg-transparent px-4 py-2.5 text-sm text-white placeholder:text-neutral-500 focus:border-white focus:outline-none transition-colors" + /> +
+ ); +} diff --git a/frontend/src/constants/onboarding.tsx b/frontend/src/constants/onboarding.tsx new file mode 100644 index 0000000000..3dc7715a8c --- /dev/null +++ b/frontend/src/constants/onboarding.tsx @@ -0,0 +1,101 @@ +import { I18nKey } from "#/i18n/declaration"; + +export type OnboardingAppMode = "saas" | "self-hosted"; + +interface BaseOnboardingQuestion { + id: string; + app_mode: OnboardingAppMode[]; + questionKey: I18nKey; + subtitleKey?: I18nKey; +} + +interface InputQuestion extends BaseOnboardingQuestion { + type: "input"; + inputOptions: { key: I18nKey; id: string }[]; +} + +interface SingleSelectQuestion extends BaseOnboardingQuestion { + type: "single"; + answerOptions: { key: I18nKey; id: string }[]; +} + +interface MultiSelectQuestion extends BaseOnboardingQuestion { + type: "multi"; + answerOptions: { key: I18nKey; id: string }[]; +} + +export type OnboardingQuestion = + | InputQuestion + | SingleSelectQuestion + | MultiSelectQuestion; + +export const ONBOARDING_FORM: OnboardingQuestion[] = [ + { + id: "org_name", + type: "input", + app_mode: ["self-hosted"], + questionKey: I18nKey.ONBOARDING$ORG_NAME_QUESTION, + inputOptions: [ + { key: I18nKey.ONBOARDING$ORG_NAME_INPUT_NAME, id: "org_name" }, + { key: I18nKey.ONBOARDING$ORG_NAME_INPUT_DOMAIN, id: "org_domain" }, + ], + }, + { + id: "org_size", + type: "single", + app_mode: ["saas", "self-hosted"], + questionKey: I18nKey.ONBOARDING$ORG_SIZE_QUESTION, + subtitleKey: I18nKey.ONBOARDING$ORG_SIZE_SUBTITLE, + answerOptions: [ + { key: I18nKey.ONBOARDING$ORG_SIZE_SOLO, id: "solo" }, + { key: I18nKey.ONBOARDING$ORG_SIZE_2_10, id: "org_2_10" }, + { key: I18nKey.ONBOARDING$ORG_SIZE_11_50, id: "org_11_50" }, + { key: I18nKey.ONBOARDING$ORG_SIZE_51_200, id: "org_51_200" }, + { key: I18nKey.ONBOARDING$ORG_SIZE_200_PLUS, id: "org_200_plus" }, + ], + }, + { + id: "use_case", + type: "multi", + app_mode: ["saas", "self-hosted"], + questionKey: I18nKey.ONBOARDING$USE_CASE_QUESTION, + subtitleKey: I18nKey.ONBOARDING$USE_CASE_SUBTITLE, + answerOptions: [ + { key: I18nKey.ONBOARDING$USE_CASE_NEW_FEATURES, id: "new_features" }, + { + key: I18nKey.ONBOARDING$USE_CASE_APP_FROM_SCRATCH, + id: "app_from_scratch", + }, + { key: I18nKey.ONBOARDING$USE_CASE_FIXING_BUGS, id: "fixing_bugs" }, + { key: I18nKey.ONBOARDING$USE_CASE_REFACTORING, id: "refactoring" }, + { + key: I18nKey.ONBOARDING$USE_CASE_AUTOMATING_TASKS, + id: "automating_tasks", + }, + { key: I18nKey.ONBOARDING$USE_CASE_NOT_SURE, id: "not_sure" }, + ], + }, + { + id: "role", + type: "single", + app_mode: ["saas"], + questionKey: I18nKey.ONBOARDING$ROLE_QUESTION, + answerOptions: [ + { + key: I18nKey.ONBOARDING$ROLE_SOFTWARE_ENGINEER, + id: "software_engineer", + }, + { + key: I18nKey.ONBOARDING$ROLE_ENGINEERING_MANAGER, + id: "engineering_manager", + }, + { key: I18nKey.ONBOARDING$ROLE_CTO_FOUNDER, id: "cto_founder" }, + { + key: I18nKey.ONBOARDING$ROLE_PRODUCT_OPERATIONS, + id: "product_operations", + }, + { key: I18nKey.ONBOARDING$ROLE_STUDENT_HOBBYIST, id: "student_hobbyist" }, + { key: I18nKey.ONBOARDING$ROLE_OTHER, id: "other" }, + ], + }, +]; diff --git a/frontend/src/hooks/mutation/use-submit-onboarding.ts b/frontend/src/hooks/mutation/use-submit-onboarding.ts index 63fbebf986..13e9eead10 100644 --- a/frontend/src/hooks/mutation/use-submit-onboarding.ts +++ b/frontend/src/hooks/mutation/use-submit-onboarding.ts @@ -3,7 +3,7 @@ import { useNavigate } from "react-router"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; type SubmitOnboardingArgs = { - selections: Record; + selections: Record; }; export const useSubmitOnboarding = () => { diff --git a/frontend/src/hooks/query/use-config.ts b/frontend/src/hooks/query/use-config.ts index a168be984b..8cb877f6f9 100644 --- a/frontend/src/hooks/query/use-config.ts +++ b/frontend/src/hooks/query/use-config.ts @@ -2,7 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import OptionService from "#/api/option-service/option-service.api"; import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page"; -export const useConfig = () => { +interface UseConfigOptions { + enabled?: boolean; +} + +export const useConfig = (options?: UseConfigOptions) => { const isOnIntermediatePage = useIsOnIntermediatePage(); return useQuery({ @@ -10,6 +14,6 @@ export const useConfig = () => { queryFn: OptionService.getConfig, staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes, - enabled: !isOnIntermediatePage, + enabled: options?.enabled ?? !isOnIntermediatePage, }); }; diff --git a/frontend/src/hooks/use-tracking.ts b/frontend/src/hooks/use-tracking.ts index fea2f29d86..70fc98f810 100644 --- a/frontend/src/hooks/use-tracking.ts +++ b/frontend/src/hooks/use-tracking.ts @@ -110,9 +110,9 @@ export const useTracking = () => { orgSize, useCase, }: { - role: string; - orgSize: string; - useCase: string; + role?: string; + orgSize?: string; + useCase?: string[]; }) => { posthog.capture("onboarding_completed", { role, diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 51f1bd288c..9d85c9528b 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1113,28 +1113,31 @@ export enum I18nKey { ORG$NO_MEMBERS_FOUND = "ORG$NO_MEMBERS_FOUND", ORG$NO_MEMBERS_MATCHING_FILTER = "ORG$NO_MEMBERS_MATCHING_FILTER", ORG$FAILED_TO_LOAD_MEMBERS = "ORG$FAILED_TO_LOAD_MEMBERS", - ONBOARDING$STEP1_TITLE = "ONBOARDING$STEP1_TITLE", - ONBOARDING$STEP1_SUBTITLE = "ONBOARDING$STEP1_SUBTITLE", - ONBOARDING$SOFTWARE_ENGINEER = "ONBOARDING$SOFTWARE_ENGINEER", - ONBOARDING$ENGINEERING_MANAGER = "ONBOARDING$ENGINEERING_MANAGER", - ONBOARDING$CTO_FOUNDER = "ONBOARDING$CTO_FOUNDER", - ONBOARDING$PRODUCT_OPERATIONS = "ONBOARDING$PRODUCT_OPERATIONS", - ONBOARDING$STUDENT_HOBBYIST = "ONBOARDING$STUDENT_HOBBYIST", - ONBOARDING$OTHER = "ONBOARDING$OTHER", - ONBOARDING$STEP2_TITLE = "ONBOARDING$STEP2_TITLE", - ONBOARDING$SOLO = "ONBOARDING$SOLO", - ONBOARDING$ORG_2_10 = "ONBOARDING$ORG_2_10", - ONBOARDING$ORG_11_50 = "ONBOARDING$ORG_11_50", - ONBOARDING$ORG_51_200 = "ONBOARDING$ORG_51_200", - ONBOARDING$ORG_200_1000 = "ONBOARDING$ORG_200_1000", - ONBOARDING$ORG_1000_PLUS = "ONBOARDING$ORG_1000_PLUS", - ONBOARDING$STEP3_TITLE = "ONBOARDING$STEP3_TITLE", - ONBOARDING$NEW_FEATURES = "ONBOARDING$NEW_FEATURES", - ONBOARDING$APP_FROM_SCRATCH = "ONBOARDING$APP_FROM_SCRATCH", - ONBOARDING$FIXING_BUGS = "ONBOARDING$FIXING_BUGS", - ONBOARDING$REFACTORING = "ONBOARDING$REFACTORING", - ONBOARDING$AUTOMATING_TASKS = "ONBOARDING$AUTOMATING_TASKS", - ONBOARDING$NOT_SURE = "ONBOARDING$NOT_SURE", + ONBOARDING$ORG_NAME_QUESTION = "ONBOARDING$ORG_NAME_QUESTION", + ONBOARDING$ORG_NAME_INPUT_NAME = "ONBOARDING$ORG_NAME_INPUT_NAME", + ONBOARDING$ORG_NAME_INPUT_DOMAIN = "ONBOARDING$ORG_NAME_INPUT_DOMAIN", + ONBOARDING$ORG_SIZE_QUESTION = "ONBOARDING$ORG_SIZE_QUESTION", + ONBOARDING$ORG_SIZE_SUBTITLE = "ONBOARDING$ORG_SIZE_SUBTITLE", + ONBOARDING$ORG_SIZE_SOLO = "ONBOARDING$ORG_SIZE_SOLO", + ONBOARDING$ORG_SIZE_2_10 = "ONBOARDING$ORG_SIZE_2_10", + ONBOARDING$ORG_SIZE_11_50 = "ONBOARDING$ORG_SIZE_11_50", + ONBOARDING$ORG_SIZE_51_200 = "ONBOARDING$ORG_SIZE_51_200", + ONBOARDING$ORG_SIZE_200_PLUS = "ONBOARDING$ORG_SIZE_200_PLUS", + ONBOARDING$USE_CASE_QUESTION = "ONBOARDING$USE_CASE_QUESTION", + ONBOARDING$USE_CASE_SUBTITLE = "ONBOARDING$USE_CASE_SUBTITLE", + ONBOARDING$USE_CASE_NEW_FEATURES = "ONBOARDING$USE_CASE_NEW_FEATURES", + ONBOARDING$USE_CASE_APP_FROM_SCRATCH = "ONBOARDING$USE_CASE_APP_FROM_SCRATCH", + ONBOARDING$USE_CASE_FIXING_BUGS = "ONBOARDING$USE_CASE_FIXING_BUGS", + ONBOARDING$USE_CASE_REFACTORING = "ONBOARDING$USE_CASE_REFACTORING", + ONBOARDING$USE_CASE_AUTOMATING_TASKS = "ONBOARDING$USE_CASE_AUTOMATING_TASKS", + ONBOARDING$USE_CASE_NOT_SURE = "ONBOARDING$USE_CASE_NOT_SURE", + ONBOARDING$ROLE_QUESTION = "ONBOARDING$ROLE_QUESTION", + ONBOARDING$ROLE_SOFTWARE_ENGINEER = "ONBOARDING$ROLE_SOFTWARE_ENGINEER", + ONBOARDING$ROLE_ENGINEERING_MANAGER = "ONBOARDING$ROLE_ENGINEERING_MANAGER", + ONBOARDING$ROLE_CTO_FOUNDER = "ONBOARDING$ROLE_CTO_FOUNDER", + ONBOARDING$ROLE_PRODUCT_OPERATIONS = "ONBOARDING$ROLE_PRODUCT_OPERATIONS", + ONBOARDING$ROLE_STUDENT_HOBBYIST = "ONBOARDING$ROLE_STUDENT_HOBBYIST", + ONBOARDING$ROLE_OTHER = "ONBOARDING$ROLE_OTHER", ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON", ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON", ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 7455ad2436..e1cbb821c1 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -17807,135 +17807,55 @@ "de": "Mitglieder konnten nicht geladen werden", "uk": "Не вдалося завантажити членів" }, - "ONBOARDING$STEP1_TITLE": { - "en": "What's your role?", - "ja": "あなたの役割は?", - "zh-CN": "您的角色是什么?", - "zh-TW": "您的角色是什麼?", - "ko-KR": "귀하의 역할은 무엇입니까?", - "no": "Hva er din rolle?", - "ar": "ما هو دورك؟", - "de": "Was ist Ihre Rolle?", - "fr": "Quel est votre rôle ?", - "it": "Qual è il tuo ruolo?", - "pt": "Qual é o seu papel?", - "es": "¿Cuál es tu rol?", - "tr": "Rolünüz nedir?", - "uk": "Яка ваша роль?" + "ONBOARDING$ORG_NAME_QUESTION": { + "en": "What's the name of your organization?", + "ja": "あなたの組織の名前は?", + "zh-CN": "您的组织名称是什么?", + "zh-TW": "您的組織名稱是什麼?", + "ko-KR": "귀하의 조직 이름은 무엇입니까?", + "no": "Hva er navnet på organisasjonen din?", + "ar": "ما اسم منظمتك؟", + "de": "Wie lautet der Name Ihrer Organisation?", + "fr": "Quel est le nom de votre organisation ?", + "it": "Qual è il nome della tua organizzazione?", + "pt": "Qual é o nome da sua organização?", + "es": "¿Cuál es el nombre de tu organización?", + "tr": "Organizasyonunuzun adı nedir?", + "uk": "Як називається ваша організація?" }, - "ONBOARDING$STEP1_SUBTITLE": { - "en": "Select the option that best fits you", - "ja": "最も当てはまるオプションを選択してください", - "zh-CN": "选择最适合您的选项", - "zh-TW": "選擇最適合您的選項", - "ko-KR": "가장 적합한 옵션을 선택하세요", - "no": "Velg alternativet som passer deg best", - "ar": "اختر الخيار الأنسب لك", - "de": "Wählen Sie die Option, die am besten zu Ihnen passt", - "fr": "Sélectionnez l'option qui vous convient le mieux", - "it": "Seleziona l'opzione più adatta a te", - "pt": "Selecione a opção que melhor se adapta a você", - "es": "Selecciona la opción que mejor te describa", - "tr": "Size en uygun seçeneği seçin", - "uk": "Виберіть варіант, який найкраще вам підходить" + "ONBOARDING$ORG_NAME_INPUT_NAME": { + "en": "Org name", + "ja": "組織名", + "zh-CN": "组织名称", + "zh-TW": "組織名稱", + "ko-KR": "조직 이름", + "no": "Organisasjonsnavn", + "ar": "اسم المنظمة", + "de": "Organisationsname", + "fr": "Nom de l'organisation", + "it": "Nome dell'organizzazione", + "pt": "Nome da organização", + "es": "Nombre de la organización", + "tr": "Organizasyon adı", + "uk": "Назва організації" }, - "ONBOARDING$SOFTWARE_ENGINEER": { - "en": "Software engineer / developer", - "ja": "ソフトウェアエンジニア / 開発者", - "zh-CN": "软件工程师 / 开发者", - "zh-TW": "軟體工程師 / 開發者", - "ko-KR": "소프트웨어 엔지니어 / 개발자", - "no": "Programvareingeniør / utvikler", - "ar": "مهندس برمجيات / مطور", - "de": "Softwareentwickler / Entwickler", - "fr": "Ingénieur logiciel / développeur", - "it": "Ingegnere software / sviluppatore", - "pt": "Engenheiro de software / desenvolvedor", - "es": "Ingeniero de software / desarrollador", - "tr": "Yazılım mühendisi / geliştirici", - "uk": "Програмний інженер / розробник" + "ONBOARDING$ORG_NAME_INPUT_DOMAIN": { + "en": "Domain name", + "ja": "ドメイン名", + "zh-CN": "域名", + "zh-TW": "網域名稱", + "ko-KR": "도메인 이름", + "no": "Domenenavn", + "ar": "اسم النطاق", + "de": "Domainname", + "fr": "Nom de domaine", + "it": "Nome di dominio", + "pt": "Nome de domínio", + "es": "Nombre de dominio", + "tr": "Alan adı", + "uk": "Доменне ім'я" }, - "ONBOARDING$ENGINEERING_MANAGER": { - "en": "Engineering manager / tech lead", - "ja": "エンジニアリングマネージャー / テックリード", - "zh-CN": "工程经理 / 技术负责人", - "zh-TW": "工程經理 / 技術負責人", - "ko-KR": "엔지니어링 매니저 / 테크 리드", - "no": "Ingeniørsjef / teknisk leder", - "ar": "مدير هندسة / قائد تقني", - "de": "Engineering Manager / Tech Lead", - "fr": "Responsable ingénierie / tech lead", - "it": "Engineering manager / tech lead", - "pt": "Gerente de engenharia / tech lead", - "es": "Gerente de ingeniería / tech lead", - "tr": "Mühendislik müdürü / teknik lider", - "uk": "Менеджер з розробки / технічний лідер" - }, - "ONBOARDING$CTO_FOUNDER": { - "en": "CTO / founder", - "ja": "CTO / 創業者", - "zh-CN": "CTO / 创始人", - "zh-TW": "CTO / 創辦人", - "ko-KR": "CTO / 창업자", - "no": "CTO / grunnlegger", - "ar": "مدير التكنولوجيا / مؤسس", - "de": "CTO / Gründer", - "fr": "CTO / fondateur", - "it": "CTO / fondatore", - "pt": "CTO / fundador", - "es": "CTO / fundador", - "tr": "CTO / kurucu", - "uk": "CTO / засновник" - }, - "ONBOARDING$PRODUCT_OPERATIONS": { - "en": "Product or operations role", - "ja": "プロダクトまたはオペレーションの役割", - "zh-CN": "产品或运营角色", - "zh-TW": "產品或營運角色", - "ko-KR": "제품 또는 운영 역할", - "no": "Produkt- eller driftsrolle", - "ar": "دور المنتج أو العمليات", - "de": "Produkt- oder Betriebsrolle", - "fr": "Rôle produit ou opérations", - "it": "Ruolo prodotto o operazioni", - "pt": "Função de produto ou operações", - "es": "Rol de producto u operaciones", - "tr": "Ürün veya operasyon rolü", - "uk": "Роль продукту або операцій" - }, - "ONBOARDING$STUDENT_HOBBYIST": { - "en": "Student / hobbyist", - "ja": "学生 / 趣味", - "zh-CN": "学生 / 爱好者", - "zh-TW": "學生 / 愛好者", - "ko-KR": "학생 / 취미", - "no": "Student / hobbyist", - "ar": "طالب / هاوٍ", - "de": "Student / Hobbyist", - "fr": "Étudiant / amateur", - "it": "Studente / hobbista", - "pt": "Estudante / hobbyista", - "es": "Estudiante / aficionado", - "tr": "Öğrenci / hobi", - "uk": "Студент / хобіст" - }, - "ONBOARDING$OTHER": { - "en": "Other", - "ja": "その他", - "zh-CN": "其他", - "zh-TW": "其他", - "ko-KR": "기타", - "no": "Annet", - "ar": "أخرى", - "de": "Andere", - "fr": "Autre", - "it": "Altro", - "pt": "Outro", - "es": "Otro", - "tr": "Diğer", - "uk": "Інше" - }, - "ONBOARDING$STEP2_TITLE": { + "ONBOARDING$ORG_SIZE_QUESTION": { "en": "What size organization do you work for?", "ja": "どのくらいの規模の組織で働いていますか?", "zh-CN": "您所在的组织规模是多大?", @@ -17951,7 +17871,23 @@ "tr": "Hangi büyüklükte bir organizasyon için çalışıyorsunuz?", "uk": "Якого розміру організація, в якій ви працюєте?" }, - "ONBOARDING$SOLO": { + "ONBOARDING$ORG_SIZE_SUBTITLE": { + "en": "Select one", + "ja": "1つ選択", + "zh-CN": "选择一个", + "zh-TW": "選擇一個", + "ko-KR": "하나 선택", + "no": "Velg én", + "ar": "اختر واحداً", + "de": "Wählen Sie eine Option", + "fr": "Sélectionnez une option", + "it": "Seleziona una opzione", + "pt": "Selecione uma opção", + "es": "Seleccione una opción", + "tr": "Bir seçenek seçin", + "uk": "Виберіть один" + }, + "ONBOARDING$ORG_SIZE_SOLO": { "en": "Just me (solo)", "ja": "自分だけ(ソロ)", "zh-CN": "只有我(个人)", @@ -17967,7 +17903,7 @@ "tr": "Sadece ben (solo)", "uk": "Тільки я (соло)" }, - "ONBOARDING$ORG_2_10": { + "ONBOARDING$ORG_SIZE_2_10": { "en": "2–10 people", "ja": "2〜10人", "zh-CN": "2-10人", @@ -17983,7 +17919,7 @@ "tr": "2–10 kişi", "uk": "2–10 осіб" }, - "ONBOARDING$ORG_11_50": { + "ONBOARDING$ORG_SIZE_11_50": { "en": "11–50 people", "ja": "11〜50人", "zh-CN": "11-50人", @@ -17999,7 +17935,7 @@ "tr": "11–50 kişi", "uk": "11–50 осіб" }, - "ONBOARDING$ORG_51_200": { + "ONBOARDING$ORG_SIZE_51_200": { "en": "51–200 people", "ja": "51〜200人", "zh-CN": "51-200人", @@ -18015,39 +17951,23 @@ "tr": "51–200 kişi", "uk": "51–200 осіб" }, - "ONBOARDING$ORG_200_1000": { - "en": "200–1000 people", - "ja": "200〜1000人", - "zh-CN": "200-1000人", - "zh-TW": "200-1000人", - "ko-KR": "200-1000명", - "no": "200–1000 personer", - "ar": "200-1000 شخص", - "de": "200–1000 Personen", - "fr": "200–1000 personnes", - "it": "200–1000 persone", - "pt": "200–1000 pessoas", - "es": "200–1000 personas", - "tr": "200–1000 kişi", - "uk": "200–1000 осіб" + "ONBOARDING$ORG_SIZE_200_PLUS": { + "en": "200+ people", + "ja": "200人以上", + "zh-CN": "200+人", + "zh-TW": "200+人", + "ko-KR": "200명 이상", + "no": "200+ personer", + "ar": "أكثر من 200 شخص", + "de": "200+ Personen", + "fr": "200+ personnes", + "it": "200+ persone", + "pt": "200+ pessoas", + "es": "200+ personas", + "tr": "200+ kişi", + "uk": "200+ осіб" }, - "ONBOARDING$ORG_1000_PLUS": { - "en": "1000+ people", - "ja": "1000人以上", - "zh-CN": "1000+人", - "zh-TW": "1000+人", - "ko-KR": "1000명 이상", - "no": "1000+ personer", - "ar": "أكثر من 1000 شخص", - "de": "1000+ Personen", - "fr": "1000+ personnes", - "it": "1000+ persone", - "pt": "1000+ pessoas", - "es": "1000+ personas", - "tr": "1000+ kişi", - "uk": "1000+ осіб" - }, - "ONBOARDING$STEP3_TITLE": { + "ONBOARDING$USE_CASE_QUESTION": { "en": "What use cases are you looking to use OpenHands for?", "ja": "OpenHandsをどのような用途で使用したいですか?", "zh-CN": "您希望将 OpenHands 用于哪些场景?", @@ -18063,7 +17983,23 @@ "tr": "OpenHands'i hangi kullanım alanları için kullanmak istiyorsunuz?", "uk": "Для яких випадків використання ви хочете використовувати OpenHands?" }, - "ONBOARDING$NEW_FEATURES": { + "ONBOARDING$USE_CASE_SUBTITLE": { + "en": "Check all that apply", + "ja": "該当するものをすべて選択", + "zh-CN": "选择所有适用的", + "zh-TW": "選擇所有適用的", + "ko-KR": "해당되는 모든 항목 선택", + "no": "Velg alle som gjelder", + "ar": "اختر كل ما ينطبق", + "de": "Wählen Sie alle zutreffenden", + "fr": "Cochez toutes les options applicables", + "it": "Seleziona tutte le opzioni applicabili", + "pt": "Selecione todas as opções aplicáveis", + "es": "Selecciona todas las que apliquen", + "tr": "Geçerli olanların tümünü seçin", + "uk": "Виберіть усі, що стосуються" + }, + "ONBOARDING$USE_CASE_NEW_FEATURES": { "en": "Writing new features to existing products", "ja": "既存の製品に新機能を追加", "zh-CN": "为现有产品编写新功能", @@ -18079,7 +18015,7 @@ "tr": "Mevcut ürünlere yeni özellikler yazmak", "uk": "Написання нових функцій для існуючих продуктів" }, - "ONBOARDING$APP_FROM_SCRATCH": { + "ONBOARDING$USE_CASE_APP_FROM_SCRATCH": { "en": "Starting an app from scratch", "ja": "ゼロからアプリを開発", "zh-CN": "从头开始创建应用", @@ -18095,7 +18031,7 @@ "tr": "Sıfırdan bir uygulama başlatmak", "uk": "Створення додатку з нуля" }, - "ONBOARDING$FIXING_BUGS": { + "ONBOARDING$USE_CASE_FIXING_BUGS": { "en": "Fixing bugs", "ja": "バグの修正", "zh-CN": "修复漏洞", @@ -18111,7 +18047,7 @@ "tr": "Hataları düzeltmek", "uk": "Виправлення помилок" }, - "ONBOARDING$REFACTORING": { + "ONBOARDING$USE_CASE_REFACTORING": { "en": "Refactoring existing code / eliminating tech debt", "ja": "既存コードのリファクタリング / 技術的負債の解消", "zh-CN": "重构现有代码 / 消除技术债务", @@ -18127,7 +18063,7 @@ "tr": "Mevcut kodu yeniden düzenlemek / teknik borcu ortadan kaldırmak", "uk": "Рефакторинг існуючого коду / усунення технічного боргу" }, - "ONBOARDING$AUTOMATING_TASKS": { + "ONBOARDING$USE_CASE_AUTOMATING_TASKS": { "en": "Automating repetitive coding tasks", "ja": "繰り返しのコーディング作業の自動化", "zh-CN": "自动化重复性编码任务", @@ -18143,7 +18079,7 @@ "tr": "Tekrarlayan kodlama görevlerini otomatikleştirmek", "uk": "Автоматизація повторюваних завдань кодування" }, - "ONBOARDING$NOT_SURE": { + "ONBOARDING$USE_CASE_NOT_SURE": { "en": "Not sure yet", "ja": "まだ決めていない", "zh-CN": "尚未确定", @@ -18159,6 +18095,118 @@ "tr": "Henüz emin değilim", "uk": "Ще не впевнений" }, + "ONBOARDING$ROLE_QUESTION": { + "en": "What's your role?", + "ja": "あなたの役割は?", + "zh-CN": "您的角色是什么?", + "zh-TW": "您的角色是什麼?", + "ko-KR": "귀하의 역할은 무엇입니까?", + "no": "Hva er din rolle?", + "ar": "ما هو دورك؟", + "de": "Was ist Ihre Rolle?", + "fr": "Quel est votre rôle ?", + "it": "Qual è il tuo ruolo?", + "pt": "Qual é o seu papel?", + "es": "¿Cuál es tu rol?", + "tr": "Rolünüz nedir?", + "uk": "Яка ваша роль?" + }, + "ONBOARDING$ROLE_SOFTWARE_ENGINEER": { + "en": "Software engineer / developer", + "ja": "ソフトウェアエンジニア / 開発者", + "zh-CN": "软件工程师 / 开发者", + "zh-TW": "軟體工程師 / 開發者", + "ko-KR": "소프트웨어 엔지니어 / 개발자", + "no": "Programvareingeniør / utvikler", + "ar": "مهندس برمجيات / مطور", + "de": "Softwareentwickler / Entwickler", + "fr": "Ingénieur logiciel / développeur", + "it": "Ingegnere software / sviluppatore", + "pt": "Engenheiro de software / desenvolvedor", + "es": "Ingeniero de software / desarrollador", + "tr": "Yazılım mühendisi / geliştirici", + "uk": "Програмний інженер / розробник" + }, + "ONBOARDING$ROLE_ENGINEERING_MANAGER": { + "en": "Engineering manager / tech lead", + "ja": "エンジニアリングマネージャー / テックリード", + "zh-CN": "工程经理 / 技术负责人", + "zh-TW": "工程經理 / 技術負責人", + "ko-KR": "엔지니어링 매니저 / 테크 리드", + "no": "Ingeniørsjef / teknisk leder", + "ar": "مدير هندسة / قائد تقني", + "de": "Engineering Manager / Tech Lead", + "fr": "Responsable ingénierie / tech lead", + "it": "Engineering manager / tech lead", + "pt": "Gerente de engenharia / tech lead", + "es": "Gerente de ingeniería / tech lead", + "tr": "Mühendislik müdürü / teknik lider", + "uk": "Менеджер з розробки / технічний лідер" + }, + "ONBOARDING$ROLE_CTO_FOUNDER": { + "en": "CTO / founder", + "ja": "CTO / 創業者", + "zh-CN": "CTO / 创始人", + "zh-TW": "CTO / 創辦人", + "ko-KR": "CTO / 창업자", + "no": "CTO / grunnlegger", + "ar": "مدير التكنولوجيا / مؤسس", + "de": "CTO / Gründer", + "fr": "CTO / fondateur", + "it": "CTO / fondatore", + "pt": "CTO / fundador", + "es": "CTO / fundador", + "tr": "CTO / kurucu", + "uk": "CTO / засновник" + }, + "ONBOARDING$ROLE_PRODUCT_OPERATIONS": { + "en": "Product or operations role", + "ja": "プロダクトまたはオペレーションの役割", + "zh-CN": "产品或运营角色", + "zh-TW": "產品或營運角色", + "ko-KR": "제품 또는 운영 역할", + "no": "Produkt- eller driftsrolle", + "ar": "دور المنتج أو العمليات", + "de": "Produkt- oder Betriebsrolle", + "fr": "Rôle produit ou opérations", + "it": "Ruolo prodotto o operazioni", + "pt": "Função de produto ou operações", + "es": "Rol de producto u operaciones", + "tr": "Ürün veya operasyon rolü", + "uk": "Роль продукту або операцій" + }, + "ONBOARDING$ROLE_STUDENT_HOBBYIST": { + "en": "Student / hobbyist", + "ja": "学生 / 趣味", + "zh-CN": "学生 / 爱好者", + "zh-TW": "學生 / 愛好者", + "ko-KR": "학생 / 취미", + "no": "Student / hobbyist", + "ar": "طالب / هاوٍ", + "de": "Student / Hobbyist", + "fr": "Étudiant / amateur", + "it": "Studente / hobbista", + "pt": "Estudante / hobbyista", + "es": "Estudiante / aficionado", + "tr": "Öğrenci / hobi", + "uk": "Студент / хобіст" + }, + "ONBOARDING$ROLE_OTHER": { + "en": "Other", + "ja": "その他", + "zh-CN": "其他", + "zh-TW": "其他", + "ko-KR": "기타", + "no": "Annet", + "ar": "أخرى", + "de": "Andere", + "fr": "Autre", + "it": "Altro", + "pt": "Outro", + "es": "Otro", + "tr": "Diğer", + "uk": "Інше" + }, "ONBOARDING$NEXT_BUTTON": { "en": "Next", "ja": "次へ", diff --git a/frontend/src/routes/onboarding-form.tsx b/frontend/src/routes/onboarding-form.tsx index e019e225fa..6d5c092247 100644 --- a/frontend/src/routes/onboarding-form.tsx +++ b/frontend/src/routes/onboarding-form.tsx @@ -1,8 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, redirect } from "react-router"; -import OptionService from "#/api/option-service/option-service.api"; -import { queryClient } from "#/query-client-config"; import StepHeader from "#/components/features/onboarding/step-header"; import { StepContent } from "#/components/features/onboarding/step-content"; import { BrandButton } from "#/components/features/settings/brand-button"; @@ -13,159 +11,154 @@ import { useTracking } from "#/hooks/use-tracking"; import { ENABLE_ONBOARDING } from "#/utils/feature-flags"; import { cn } from "#/utils/utils"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { useConfig } from "#/hooks/query/use-config"; +import { + ONBOARDING_FORM, + OnboardingQuestion, + OnboardingAppMode, +} from "#/constants/onboarding"; export const clientLoader = async () => { - const config = await queryClient.ensureQueryData({ - queryKey: ["config"], - queryFn: OptionService.getConfig, - }); - - if (config.app_mode !== "saas" || !ENABLE_ONBOARDING()) { + if (!ENABLE_ONBOARDING()) { return redirect("/"); } return null; }; -interface StepOption { - id: string; - labelKey?: I18nKey; - label?: string; +type OnboardingAnswers = Record; + +function getOnboardingAppMode(): OnboardingAppMode { + // TODO: query for app mode (saas or self hosted super user) + return "saas"; } -interface FormStep { - id: string; - titleKey: I18nKey; - options: StepOption[]; +function getAnswerAsArray(answers: OnboardingAnswers, key: string): string[] { + const value = answers[key]; + if (!value) return []; + return Array.isArray(value) ? value : [value]; } -const steps: FormStep[] = [ - { - id: "step1", - titleKey: I18nKey.ONBOARDING$STEP1_TITLE, - options: [ - { - id: "software_engineer", - labelKey: I18nKey.ONBOARDING$SOFTWARE_ENGINEER, - }, - { - id: "engineering_manager", - labelKey: I18nKey.ONBOARDING$ENGINEERING_MANAGER, - }, - { - id: "cto_founder", - labelKey: I18nKey.ONBOARDING$CTO_FOUNDER, - }, - { - id: "product_operations", - labelKey: I18nKey.ONBOARDING$PRODUCT_OPERATIONS, - }, - { - id: "student_hobbyist", - labelKey: I18nKey.ONBOARDING$STUDENT_HOBBYIST, - }, - { - id: "other", - labelKey: I18nKey.ONBOARDING$OTHER, - }, - ], - }, - { - id: "step2", - titleKey: I18nKey.ONBOARDING$STEP2_TITLE, - options: [ - { - id: "solo", - labelKey: I18nKey.ONBOARDING$SOLO, - }, - { - id: "org_2_10", - labelKey: I18nKey.ONBOARDING$ORG_2_10, - }, - { - id: "org_11_50", - labelKey: I18nKey.ONBOARDING$ORG_11_50, - }, - { - id: "org_51_200", - labelKey: I18nKey.ONBOARDING$ORG_51_200, - }, - { - id: "org_200_1000", - labelKey: I18nKey.ONBOARDING$ORG_200_1000, - }, - { - id: "org_1000_plus", - labelKey: I18nKey.ONBOARDING$ORG_1000_PLUS, - }, - ], - }, - { - id: "step3", - titleKey: I18nKey.ONBOARDING$STEP3_TITLE, - options: [ - { - id: "new_features", - labelKey: I18nKey.ONBOARDING$NEW_FEATURES, - }, - { - id: "app_from_scratch", - labelKey: I18nKey.ONBOARDING$APP_FROM_SCRATCH, - }, - { - id: "fixing_bugs", - labelKey: I18nKey.ONBOARDING$FIXING_BUGS, - }, - { - id: "refactoring", - labelKey: I18nKey.ONBOARDING$REFACTORING, - }, - { - id: "automating_tasks", - labelKey: I18nKey.ONBOARDING$AUTOMATING_TASKS, - }, - { - id: "not_sure", - labelKey: I18nKey.ONBOARDING$NOT_SURE, - }, - ], - }, -]; +function getTranslatedOptions( + step: OnboardingQuestion, + t: (key: I18nKey) => string, +) { + if (step.type === "input") return undefined; + return step.answerOptions.map((option) => ({ + id: option.id, + label: t(option.key), + })); +} + +function getTranslatedInputFields( + step: OnboardingQuestion, + t: (key: I18nKey) => string, +) { + if (step.type !== "input") return undefined; + return step.inputOptions.map((field) => ({ + id: field.id, + label: t(field.key), + })); +} function OnboardingForm() { const { t } = useTranslation(); const navigate = useNavigate(); + const config = useConfig({ enabled: true }); const { mutate: submitOnboarding } = useSubmitOnboarding(); const { trackOnboardingCompleted } = useTracking(); - const [currentStepIndex, setCurrentStepIndex] = React.useState(0); - const [selections, setSelections] = React.useState>( - {}, + const onboardingAppMode: OnboardingAppMode = getOnboardingAppMode(); + + const steps = React.useMemo( + () => + ONBOARDING_FORM.filter((step) => + step.app_mode.includes(onboardingAppMode), + ), + [onboardingAppMode], ); + const [currentStepIndex, setCurrentStepIndex] = React.useState(0); + const [answers, setAnswers] = React.useState({}); + const currentStep = steps[currentStepIndex]; const isLastStep = currentStepIndex === steps.length - 1; const isFirstStep = currentStepIndex === 0; - const currentSelection = selections[currentStep.id] || null; + + const currentSelections = React.useMemo( + () => (currentStep ? getAnswerAsArray(answers, currentStep.id) : []), + [answers, currentStep], + ); + + const isStepComplete = React.useMemo(() => { + if (!currentStep) return false; + + if (currentStep.type === "input") { + return currentStep.inputOptions.every((field) => { + const value = answers[field.id]; + return typeof value === "string" && value.trim() !== ""; + }); + } + return currentSelections.length > 0; + }, [currentStep, answers, currentSelections]); + + const inputValues = React.useMemo(() => { + const result: Record = {}; + for (const [key, value] of Object.entries(answers)) { + if (typeof value === "string") { + result[key] = value; + } + } + return result; + }, [answers]); const handleSelectOption = (optionId: string) => { - setSelections((prev) => ({ + if (!currentStep) return; + + if (currentStep.type === "multi") { + setAnswers((prev) => { + const currentArray = getAnswerAsArray(prev, currentStep.id); + + if (currentArray.includes(optionId)) { + return { + ...prev, + [currentStep.id]: currentArray.filter((id) => id !== optionId), + }; + } + return { + ...prev, + [currentStep.id]: [...currentArray, optionId], + }; + }); + } else { + setAnswers((prev) => ({ + ...prev, + [currentStep.id]: optionId, + })); + } + }; + + const handleInputChange = (fieldId: string, value: string) => { + setAnswers((prev) => ({ ...prev, - [currentStep.id]: optionId, + [fieldId]: value, })); }; const handleNext = () => { if (isLastStep) { - submitOnboarding({ selections }); - try { + submitOnboarding({ selections: answers }); + + // Only track onboarding for SaaS users + if (config.data?.app_mode === "saas") { trackOnboardingCompleted({ - role: selections.step1, - orgSize: selections.step2, - useCase: selections.step3, + role: typeof answers.role === "string" ? answers.role : undefined, + orgSize: + typeof answers.org_size === "string" ? answers.org_size : undefined, + useCase: Array.isArray(answers.use_case) + ? answers.use_case + : undefined, }); - } catch (error) { - console.error("Failed to track onboarding:", error); } } else { setCurrentStepIndex((prev) => prev + 1); @@ -180,10 +173,12 @@ function OnboardingForm() { } }; - const translatedOptions = currentStep.options.map((option) => ({ - id: option.id, - label: option.labelKey ? t(option.labelKey) : option.label!, - })); + if (!currentStep) { + return null; + } + + const translatedOptions = getTranslatedOptions(currentStep, t); + const translatedInputFields = getTranslatedInputFields(currentStep, t); return ( @@ -195,14 +190,20 @@ function OnboardingForm() {
{t( From 3b215c4ad1c9982c0a4a68f1fe589b5adf0a17d3 Mon Sep 17 00:00:00 2001 From: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:52:02 -0700 Subject: [PATCH 48/92] feat(frontend): context menu cta (#13338) Co-authored-by: openhands --- .../context-menu-container.test.tsx | 78 ++++++++ .../context-menu/context-menu-cta.test.tsx | 60 ++++++ .../device-verify/enterprise-banner.test.tsx | 12 +- .../features/user/user-context-menu.test.tsx | 97 ++++++++++ .../__tests__/routes/device-verify.test.tsx | 10 +- .../context-menu/context-menu-container.tsx | 39 ++++ .../context-menu/context-menu-cta.tsx | 65 +++++++ .../device-verify/enterprise-banner.tsx | 4 +- .../new-conversation/new-conversation.tsx | 2 +- .../components/features/org/org-selector.tsx | 1 + .../features/user/user-context-menu.tsx | 173 +++++++++--------- frontend/src/hooks/use-tracking.ts | 8 + frontend/src/i18n/declaration.ts | 3 + frontend/src/i18n/translation.json | 48 +++++ frontend/src/icons/stacked.svg | 6 + frontend/src/routes/device-verify.tsx | 4 +- frontend/src/tailwind.css | 6 + frontend/src/ui/card.tsx | 39 ++-- frontend/src/ui/context-menu.tsx | 75 +++++--- frontend/src/ui/dropdown/dropdown-menu.tsx | 2 +- frontend/src/ui/dropdown/dropdown.tsx | 3 + frontend/src/utils/feature-flags.ts | 3 +- 22 files changed, 582 insertions(+), 156 deletions(-) create mode 100644 frontend/__tests__/components/context-menu/context-menu-container.test.tsx create mode 100644 frontend/__tests__/components/context-menu/context-menu-cta.test.tsx create mode 100644 frontend/src/components/features/context-menu/context-menu-container.tsx create mode 100644 frontend/src/components/features/context-menu/context-menu-cta.tsx create mode 100644 frontend/src/icons/stacked.svg diff --git a/frontend/__tests__/components/context-menu/context-menu-container.test.tsx b/frontend/__tests__/components/context-menu/context-menu-container.test.tsx new file mode 100644 index 0000000000..29e74021e2 --- /dev/null +++ b/frontend/__tests__/components/context-menu/context-menu-container.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ContextMenuContainer } from "#/components/features/context-menu/context-menu-container"; + +describe("ContextMenuContainer", () => { + const user = userEvent.setup(); + const onCloseMock = vi.fn(); + + it("should render children", () => { + render( + +
Child 1
+
Child 2
+
, + ); + + expect(screen.getByTestId("child-1")).toBeInTheDocument(); + expect(screen.getByTestId("child-2")).toBeInTheDocument(); + }); + + it("should apply consistent base styling", () => { + render( + +
Content
+
, + ); + + const container = screen.getByTestId("test-container"); + expect(container).toHaveClass("bg-[#050505]"); + expect(container).toHaveClass("border"); + expect(container).toHaveClass("border-[#242424]"); + expect(container).toHaveClass("rounded-[12px]"); + expect(container).toHaveClass("p-[25px]"); + expect(container).toHaveClass("context-menu-box-shadow"); + }); + + it("should call onClose when clicking outside", async () => { + render( + +
Content
+
, + ); + + await user.click(document.body); + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + it("should render children in a flex row layout", () => { + render( + +
Child 1
+
Child 2
+
, + ); + + const container = screen.getByTestId("test-container"); + const innerDiv = container.firstChild as HTMLElement; + expect(innerDiv).toHaveClass("flex"); + expect(innerDiv).toHaveClass("flex-row"); + expect(innerDiv).toHaveClass("gap-4"); + }); + + it("should apply additional className when provided", () => { + render( + +
Content
+
, + ); + + const container = screen.getByTestId("test-container"); + expect(container).toHaveClass("custom-class"); + }); +}); diff --git a/frontend/__tests__/components/context-menu/context-menu-cta.test.tsx b/frontend/__tests__/components/context-menu/context-menu-cta.test.tsx new file mode 100644 index 0000000000..ba80c81410 --- /dev/null +++ b/frontend/__tests__/components/context-menu/context-menu-cta.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { ContextMenuCTA } from "#/components/features/context-menu/context-menu-cta"; + +// Mock useTracking hook +const mockTrackSaasSelfhostedInquiry = vi.fn(); +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry, + }), +})); + +describe("ContextMenuCTA", () => { + it("should render the CTA component", () => { + render(); + + expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument(); + expect(screen.getByText("CTA$ENTERPRISE_DESCRIPTION")).toBeInTheDocument(); + expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument(); + }); + + it("should call trackSaasSelfhostedInquiry with location 'context_menu' when Learn More is clicked", async () => { + const user = userEvent.setup(); + render(); + + const learnMoreLink = screen.getByRole("link", { + name: "CTA$LEARN_MORE", + }); + await user.click(learnMoreLink); + + expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({ + location: "context_menu", + }); + }); + + it("should render Learn More as a link with correct href and target", () => { + render(); + + const learnMoreLink = screen.getByRole("link", { + name: "CTA$LEARN_MORE", + }); + expect(learnMoreLink).toHaveAttribute( + "href", + "https://openhands.dev/enterprise/", + ); + expect(learnMoreLink).toHaveAttribute("target", "_blank"); + expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should render the stacked icon", () => { + render(); + + const contentContainer = screen.getByTestId("context-menu-cta-content"); + const icon = contentContainer.querySelector("svg"); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute("width", "40"); + expect(icon).toHaveAttribute("height", "40"); + }); +}); diff --git a/frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx b/frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx index 2568601423..8471d87c3e 100644 --- a/frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx +++ b/frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx @@ -11,23 +11,23 @@ vi.mock("posthog-js/react", () => ({ }), })); -const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({ - PROJ_USER_JOURNEY_MOCK: vi.fn(() => true), +const { ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({ + ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true), })); vi.mock("#/utils/feature-flags", () => ({ - PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(), + ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(), })); describe("EnterpriseBanner", () => { beforeEach(() => { vi.clearAllMocks(); - PROJ_USER_JOURNEY_MOCK.mockReturnValue(true); + ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true); }); describe("Feature Flag", () => { it("should not render when proj_user_journey feature flag is disabled", () => { - PROJ_USER_JOURNEY_MOCK.mockReturnValue(false); + ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false); const { container } = renderWithProviders(); @@ -36,7 +36,7 @@ describe("EnterpriseBanner", () => { }); it("should render when proj_user_journey feature flag is enabled", () => { - PROJ_USER_JOURNEY_MOCK.mockReturnValue(true); + ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true); renderWithProviders(); diff --git a/frontend/__tests__/components/features/user/user-context-menu.test.tsx b/frontend/__tests__/components/features/user/user-context-menu.test.tsx index f69de4c0d3..635f66e645 100644 --- a/frontend/__tests__/components/features/user/user-context-menu.test.tsx +++ b/frontend/__tests__/components/features/user/user-context-menu.test.tsx @@ -18,6 +18,27 @@ import { OrganizationMember } from "#/types/org"; import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; import { createMockWebClientConfig } from "#/mocks/settings-handlers"; +// Mock useBreakpoint hook +vi.mock("#/hooks/use-breakpoint", () => ({ + useBreakpoint: vi.fn(() => false), // Default to desktop (not mobile) +})); + +// Mock feature flags +const mockEnableProjUserJourney = vi.fn(() => true); +vi.mock("#/utils/feature-flags", () => ({ + ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(), +})); + +// Mock useTracking hook for CTA +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackSaasSelfhostedInquiry: vi.fn(), + }), +})); + +// Import the mocked modules +import * as breakpoint from "#/hooks/use-breakpoint"; + type UserContextMenuProps = GetComponentPropTypes; function UserContextMenuWithRootOutlet({ @@ -123,6 +144,9 @@ describe("UserContextMenu", () => { // Ensure clean state at the start of each test vi.restoreAllMocks(); useSelectedOrganizationStore.setState({ organizationId: null }); + // Reset feature flag and breakpoint mocks to defaults + mockEnableProjUserJourney.mockReturnValue(true); + vi.mocked(breakpoint.useBreakpoint).mockReturnValue(false); // Desktop by default }); afterEach(() => { @@ -630,4 +654,77 @@ describe("UserContextMenu", () => { // Verify that the dropdown shows the selected organization expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name); }); + + describe("Context Menu CTA", () => { + it("should render the CTA component in SaaS mode on desktop with feature flag enabled", async () => { + // Set SaaS mode + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ app_mode: "saas" }), + ); + + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for config to load + await waitFor(() => { + expect(screen.getByTestId("context-menu-cta")).toBeInTheDocument(); + }); + expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument(); + expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument(); + }); + + it("should not render the CTA component in OSS mode even with feature flag enabled", async () => { + // Set OSS mode + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ app_mode: "oss" }), + ); + + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for config to load + await waitFor(() => { + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument(); + expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument(); + }); + + it("should not render the CTA component on mobile even in SaaS mode with feature flag enabled", async () => { + // Set SaaS mode + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ app_mode: "saas" }), + ); + // Set mobile mode + vi.mocked(breakpoint.useBreakpoint).mockReturnValue(true); + + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for config to load + await waitFor(() => { + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument(); + expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument(); + }); + + it("should not render the CTA component when feature flag is disabled in SaaS mode", async () => { + // Set SaaS mode + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ app_mode: "saas" }), + ); + // Disable the feature flag + mockEnableProjUserJourney.mockReturnValue(false); + + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for config to load + await waitFor(() => { + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument(); + expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/__tests__/routes/device-verify.test.tsx b/frontend/__tests__/routes/device-verify.test.tsx index 47773ddbf5..289abc4643 100644 --- a/frontend/__tests__/routes/device-verify.test.tsx +++ b/frontend/__tests__/routes/device-verify.test.tsx @@ -5,12 +5,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRoutesStub } from "react-router"; import DeviceVerify from "#/routes/device-verify"; -const { useIsAuthedMock, PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({ +const { useIsAuthedMock, ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({ useIsAuthedMock: vi.fn(() => ({ data: false as boolean | undefined, isLoading: false, })), - PROJ_USER_JOURNEY_MOCK: vi.fn(() => true), + ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true), })); vi.mock("#/hooks/query/use-is-authed", () => ({ @@ -24,7 +24,7 @@ vi.mock("posthog-js/react", () => ({ })); vi.mock("#/utils/feature-flags", () => ({ - PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(), + ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(), })); const RouterStub = createRoutesStub([ @@ -67,7 +67,7 @@ describe("DeviceVerify", () => { ), ); // Enable feature flag by default - PROJ_USER_JOURNEY_MOCK.mockReturnValue(true); + ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true); }); afterEach(() => { @@ -254,7 +254,7 @@ describe("DeviceVerify", () => { }); it("should not include the EnterpriseBanner and be center-aligned when feature flag is disabled", async () => { - PROJ_USER_JOURNEY_MOCK.mockReturnValue(false); + ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false); useIsAuthedMock.mockReturnValue({ data: true, isLoading: false, diff --git a/frontend/src/components/features/context-menu/context-menu-container.tsx b/frontend/src/components/features/context-menu/context-menu-container.tsx new file mode 100644 index 0000000000..fe4ce7835c --- /dev/null +++ b/frontend/src/components/features/context-menu/context-menu-container.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { cn } from "#/utils/utils"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; + +interface ContextMenuContainerProps { + children: React.ReactNode; + onClose: () => void; + testId?: string; + className?: string; +} + +export function ContextMenuContainer({ + children, + onClose, + testId, + className, +}: ContextMenuContainerProps) { + const ref = useClickOutsideElement(onClose); + + return ( +
+
{children}
+
+ ); +} diff --git a/frontend/src/components/features/context-menu/context-menu-cta.tsx b/frontend/src/components/features/context-menu/context-menu-cta.tsx new file mode 100644 index 0000000000..74cf91683b --- /dev/null +++ b/frontend/src/components/features/context-menu/context-menu-cta.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "#/utils/utils"; +import { Card } from "#/ui/card"; +import { CardTitle } from "#/ui/card-title"; +import { Typography } from "#/ui/typography"; +import { I18nKey } from "#/i18n/declaration"; +import StackedIcon from "#/icons/stacked.svg?react"; +import { useTracking } from "#/hooks/use-tracking"; + +export function ContextMenuCTA() { + const { t } = useTranslation(); + const { trackSaasSelfhostedInquiry } = useTracking(); + + const handleLearnMoreClick = () => { + trackSaasSelfhostedInquiry({ location: "context_menu" }); + }; + + return ( + +
+ + + {t(I18nKey.CTA$ENTERPRISE_TITLE)} + + + {t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)} + + + +
+
+ ); +} diff --git a/frontend/src/components/features/device-verify/enterprise-banner.tsx b/frontend/src/components/features/device-verify/enterprise-banner.tsx index 7746bac48d..1bae456efd 100644 --- a/frontend/src/components/features/device-verify/enterprise-banner.tsx +++ b/frontend/src/components/features/device-verify/enterprise-banner.tsx @@ -3,7 +3,7 @@ import { usePostHog } from "posthog-js/react"; import { I18nKey } from "#/i18n/declaration"; import { H2, Text } from "#/ui/typography"; import CheckCircleFillIcon from "#/icons/check-circle-fill.svg?react"; -import { PROJ_USER_JOURNEY } from "#/utils/feature-flags"; +import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags"; const ENTERPRISE_FEATURE_KEYS: I18nKey[] = [ I18nKey.ENTERPRISE$FEATURE_DATA_PRIVACY, @@ -16,7 +16,7 @@ export function EnterpriseBanner() { const { t } = useTranslation(); const posthog = usePostHog(); - if (!PROJ_USER_JOURNEY()) { + if (!ENABLE_PROJ_USER_JOURNEY()) { return null; } diff --git a/frontend/src/components/features/home/new-conversation/new-conversation.tsx b/frontend/src/components/features/home/new-conversation/new-conversation.tsx index 0182ee0c10..75263f0687 100644 --- a/frontend/src/components/features/home/new-conversation/new-conversation.tsx +++ b/frontend/src/components/features/home/new-conversation/new-conversation.tsx @@ -10,7 +10,7 @@ export function NewConversation() { const { t } = useTranslation(); return ( - + }> {t(I18nKey.COMMON$START_FROM_SCRATCH)} diff --git a/frontend/src/components/features/org/org-selector.tsx b/frontend/src/components/features/org/org-selector.tsx index d5b982a112..b32f379e96 100644 --- a/frontend/src/components/features/org/org-selector.tsx +++ b/frontend/src/components/features/org/org-selector.tsx @@ -56,6 +56,7 @@ export function OrgSelector() { label: getOrgDisplayName(org), })) || [] } + className="bg-[#1F1F1F66] border-[#242424]" /> ); } diff --git a/frontend/src/components/features/user/user-context-menu.tsx b/frontend/src/components/features/user/user-context-menu.tsx index c78d6c15f3..b9094cc6d3 100644 --- a/frontend/src/components/features/user/user-context-menu.tsx +++ b/frontend/src/components/features/user/user-context-menu.tsx @@ -9,7 +9,6 @@ import { import { FiUsers } from "react-icons/fi"; import { useLogout } from "#/hooks/mutation/use-logout"; import { OrganizationUserRole } from "#/types/org"; -import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access"; import { cn } from "#/utils/utils"; import { OrgSelector } from "../org/org-selector"; @@ -18,11 +17,16 @@ import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; import DocumentIcon from "#/icons/document.svg?react"; import { Divider } from "#/ui/divider"; import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; +import { ContextMenuContainer } from "../context-menu/context-menu-container"; +import { ContextMenuCTA } from "../context-menu/context-menu-cta"; import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector"; +import { useBreakpoint } from "#/hooks/use-breakpoint"; +import { useConfig } from "#/hooks/query/use-config"; +import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags"; // Shared className for context menu list items in the user context menu const contextMenuListItemClassName = cn( - "flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded", + "flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded text-xs", ); interface UserContextMenuProps { @@ -40,9 +44,10 @@ export function UserContextMenu({ const navigate = useNavigate(); const { mutate: logout } = useLogout(); const { isPersonalOrg } = useOrgTypeAndAccess(); - const ref = useClickOutsideElement(onClose); const settingsNavItems = useSettingsNavItems(); const shouldHideSelector = useShouldHideOrgSelector(); + const isMobile = useBreakpoint(768); + const { data: config } = useConfig(); // Filter out org routes since they're handled separately via buttons in this menu const navItems = settingsNavItems.filter( @@ -51,7 +56,10 @@ export function UserContextMenu({ ); const isMember = type === "member"; + const isSaasMode = config?.app_mode === "saas"; + // CTA only renders in SaaS desktop with feature flag enabled + const showCta = isSaasMode && !isMobile && ENABLE_PROJ_USER_JOURNEY(); const handleLogout = () => { logout(); onClose(); @@ -73,96 +81,93 @@ export function UserContextMenu({ }; return ( -
-

- {t(I18nKey.ORG$ACCOUNT)} -

+ +
+

+ {t(I18nKey.ORG$ACCOUNT)} +

-
- {!shouldHideSelector && ( -
- -
- )} +
+ {!shouldHideSelector && ( +
+ +
+ )} + + {!isMember && !isPersonalOrg && ( +
+ + + {t(I18nKey.ORG$INVITE_ORG_MEMBERS)} + + + + + + + {t(I18nKey.COMMON$ORGANIZATION)} + + + + {t(I18nKey.ORG$ORGANIZATION_MEMBERS)} + + +
+ )} - {!isMember && !isPersonalOrg && (
- - - {t(I18nKey.ORG$INVITE_ORG_MEMBERS)} - - - - - - - {t(I18nKey.COMMON$ORGANIZATION)} - - - - {t(I18nKey.ORG$ORGANIZATION_MEMBERS)} - - + {navItems.map((item) => ( + + {React.cloneElement(item.icon, { + className: "text-white", + width: 16, + height: 16, + } as React.SVGProps)} + {t(item.text)} + + ))}
- )} -
-
+ + {showCta && } + ); } diff --git a/frontend/src/hooks/use-tracking.ts b/frontend/src/hooks/use-tracking.ts index 70fc98f810..a81aa46ccc 100644 --- a/frontend/src/hooks/use-tracking.ts +++ b/frontend/src/hooks/use-tracking.ts @@ -122,6 +122,13 @@ export const useTracking = () => { }); }; + const trackSaasSelfhostedInquiry = ({ location }: { location: string }) => { + posthog.capture("saas_selfhosted_inquiry", { + location, + ...commonProperties, + }); + }; + return { trackLoginButtonClick, trackConversationCreated, @@ -134,5 +141,6 @@ export const useTracking = () => { trackCreditLimitReached, trackAddTeamMembersButtonClick, trackOnboardingCompleted, + trackSaasSelfhostedInquiry, }; }; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 9d85c9528b..81b1301ace 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1171,4 +1171,7 @@ export enum I18nKey { DEVICE$CONTINUE = "DEVICE$CONTINUE", DEVICE$AUTH_REQUIRED = "DEVICE$AUTH_REQUIRED", DEVICE$SIGN_IN_PROMPT = "DEVICE$SIGN_IN_PROMPT", + CTA$ENTERPRISE_TITLE = "CTA$ENTERPRISE_TITLE", + CTA$ENTERPRISE_DESCRIPTION = "CTA$ENTERPRISE_DESCRIPTION", + CTA$LEARN_MORE = "CTA$LEARN_MORE", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index e1cbb821c1..da7b4f391f 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -18734,5 +18734,53 @@ "es": "Por favor, inicia sesión para autorizar tu dispositivo.", "tr": "Cihazınızı yetkilendirmek için lütfen giriş yapın.", "uk": "Будь ласка, увійдіть, щоб авторизувати свій пристрій." + }, + "CTA$ENTERPRISE_TITLE": { + "en": "Get OpenHands For Enterprise", + "ja": "エンタープライズ向けOpenHandsを入手", + "zh-CN": "获取企业版 OpenHands", + "zh-TW": "獲取企業版 OpenHands", + "ko-KR": "기업용 OpenHands 받기", + "no": "Få OpenHands for bedrift", + "it": "Ottieni OpenHands per Enterprise", + "pt": "Obtenha o OpenHands para Enterprise", + "es": "Obtén OpenHands para Empresas", + "ar": "احصل على OpenHands للمؤسسات", + "fr": "Obtenez OpenHands pour Entreprise", + "tr": "Kurumsal OpenHands'i Edinin", + "de": "OpenHands für Unternehmen", + "uk": "Отримайте OpenHands для підприємств" + }, + "CTA$ENTERPRISE_DESCRIPTION": { + "en": "Cloud allows you to access OpenHands anywhere and coordinate with your team like never before.", + "ja": "クラウドを使用すると、どこからでもOpenHandsにアクセスし、チームとこれまでにない方法で連携できます。", + "zh-CN": "云端让您可以随时随地访问 OpenHands,并以前所未有的方式与团队协作。", + "zh-TW": "雲端讓您可以隨時隨地存取 OpenHands,並以前所未有的方式與團隊協作。", + "ko-KR": "클라우드를 통해 어디서나 OpenHands에 접속하고 팀과 이전과는 다른 방식으로 협업할 수 있습니다.", + "no": "Cloud lar deg få tilgang til OpenHands hvor som helst og koordinere med teamet ditt som aldri før.", + "it": "Cloud ti permette di accedere a OpenHands ovunque e coordinare con il tuo team come mai prima d'ora.", + "pt": "O Cloud permite que você acesse o OpenHands de qualquer lugar e coordene com sua equipe como nunca antes.", + "es": "Cloud le permite acceder a OpenHands desde cualquier lugar y coordinar con su equipo como nunca antes.", + "ar": "يتيح لك Cloud الوصول إلى OpenHands من أي مكان والتنسيق مع فريقك بشكل لم يسبق له مثيل.", + "fr": "Cloud vous permet d'accéder à OpenHands n'importe où et de coordonner avec votre équipe comme jamais auparavant.", + "tr": "Cloud, OpenHands'e her yerden erişmenizi ve ekibinizle daha önce hiç olmadığı gibi koordinasyon sağlamanızı mümkün kılar.", + "de": "Cloud ermöglicht Ihnen den Zugriff auf OpenHands von überall und die Koordination mit Ihrem Team wie nie zuvor.", + "uk": "Cloud дозволяє отримати доступ до OpenHands будь-де та координувати роботу з вашою командою як ніколи раніше." + }, + "CTA$LEARN_MORE": { + "en": "Learn more", + "ja": "詳細を見る", + "zh-CN": "了解更多", + "zh-TW": "了解更多", + "ko-KR": "자세히 알아보기", + "no": "Lær mer", + "it": "Scopri di più", + "pt": "Saiba mais", + "es": "Más información", + "ar": "اعرف المزيد", + "fr": "En savoir plus", + "tr": "Daha fazla bilgi", + "de": "Mehr erfahren", + "uk": "Дізнатися більше" } } diff --git a/frontend/src/icons/stacked.svg b/frontend/src/icons/stacked.svg new file mode 100644 index 0000000000..3f15d38765 --- /dev/null +++ b/frontend/src/icons/stacked.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/routes/device-verify.tsx b/frontend/src/routes/device-verify.tsx index aabc94e544..b05db95240 100644 --- a/frontend/src/routes/device-verify.tsx +++ b/frontend/src/routes/device-verify.tsx @@ -5,7 +5,7 @@ import { useIsAuthed } from "#/hooks/query/use-is-authed"; import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner"; import { I18nKey } from "#/i18n/declaration"; import { H1 } from "#/ui/typography"; -import { PROJ_USER_JOURNEY } from "#/utils/feature-flags"; +import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags"; export default function DeviceVerify() { const { t } = useTranslation(); @@ -16,7 +16,7 @@ export default function DeviceVerify() { messageKey: I18nKey; } | null>(null); const [isProcessing, setIsProcessing] = useState(false); - const showEnterpriseBanner = PROJ_USER_JOURNEY(); + const showEnterpriseBanner = ENABLE_PROJ_USER_JOURNEY(); // Get user_code from URL parameters const userCode = searchParams.get("user_code"); diff --git a/frontend/src/tailwind.css b/frontend/src/tailwind.css index eee31d1d16..8cbacea7ef 100644 --- a/frontend/src/tailwind.css +++ b/frontend/src/tailwind.css @@ -376,3 +376,9 @@ animation: shine 2s linear infinite; background: radial-gradient(circle at center, rgb(24 24 27 / 85%), transparent) -200% 50% / 200% 100% no-repeat, #f4f4f5; } + +/* CTA card gradient and shadow */ +.cta-card-gradient { + background: radial-gradient(85.36% 123.38% at 50% 0%, rgba(255, 255, 255, 0.14) 0%, rgba(0, 0, 0, 0) 100%); + box-shadow: 0px 4px 6px -4px rgba(0, 0, 0, 0.1), 0px 10px 15px -3px rgba(0, 0, 0, 0.1); +} diff --git a/frontend/src/ui/card.tsx b/frontend/src/ui/card.tsx index 519cbd5730..ea420c4296 100644 --- a/frontend/src/ui/card.tsx +++ b/frontend/src/ui/card.tsx @@ -2,43 +2,30 @@ import { ReactNode } from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "#/utils/utils"; -const cardVariants = cva( - "w-full flex flex-col rounded-[12px] p-[20px] border border-[#727987] bg-[#26282D] relative", - { - variants: { - gap: { - default: "gap-[10px]", - large: "gap-6", - }, - minHeight: { - default: "min-h-[286px] md:min-h-auto", - small: "min-h-[263.5px]", - }, - }, - defaultVariants: { - gap: "default", - minHeight: "default", +const cardVariants = cva("flex", { + variants: { + theme: { + default: "relative bg-[#26282D] border border-[#727987] rounded-xl", + outlined: "relative bg-transparent border border-[#727987] rounded-xl", + dark: "relative bg-black border border-[#242424] rounded-2xl", }, }, -); + defaultVariants: { + theme: "default", + }, +}); interface CardProps extends VariantProps { - children: ReactNode; + children?: ReactNode; className?: string; testId?: string; } -export function Card({ - children, - className = "", - testId, - gap, - minHeight, -}: CardProps) { +export function Card({ children, className, testId, theme }: CardProps) { return (
{children}
diff --git a/frontend/src/ui/context-menu.tsx b/frontend/src/ui/context-menu.tsx index 1a6af5cbcd..4d31d629bb 100644 --- a/frontend/src/ui/context-menu.tsx +++ b/frontend/src/ui/context-menu.tsx @@ -2,42 +2,53 @@ import React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "#/utils/utils"; -const contextMenuVariants = cva( - "absolute bg-tertiary rounded-[6px] text-white overflow-hidden z-50 context-menu-box-shadow", - { - variants: { - size: { - compact: "py-1 px-1", - default: "py-[6px] px-1", - }, - layout: { - vertical: "flex flex-col gap-2", - }, - position: { - top: "bottom-full", - bottom: "top-full", - }, - spacing: { - default: "mt-2", - }, - alignment: { - left: "left-0", - right: "right-0", - }, +const contextMenuVariants = cva("text-white overflow-hidden z-50", { + variants: { + theme: { + default: + "absolute bg-tertiary rounded-[6px] context-menu-box-shadow py-[6px] px-1", + naked: "relative", }, - defaultVariants: { - size: "default", - layout: "vertical", - spacing: "default", + size: { + compact: "py-1 px-1", + default: "", + }, + layout: { + vertical: "flex flex-col gap-2", + }, + position: { + top: "bottom-full", + bottom: "top-full", + }, + spacing: { + default: "mt-2", + none: "", + }, + alignment: { + left: "left-0", + right: "right-0", }, }, -); + compoundVariants: [ + { + theme: "naked", + className: "shadow-none", + }, + ], + defaultVariants: { + theme: "default", + size: "default", + layout: "vertical", + spacing: "default", + }, +}); interface ContextMenuProps { ref?: React.RefObject; testId?: string; children: React.ReactNode; className?: React.HTMLAttributes["className"]; + theme?: VariantProps["theme"]; size?: VariantProps["size"]; layout?: VariantProps["layout"]; position?: VariantProps["position"]; @@ -50,6 +61,7 @@ export function ContextMenu({ children, className, ref, + theme, size, layout, position, @@ -61,7 +73,14 @@ export function ContextMenu({ data-testid={testId} ref={ref} className={cn( - contextMenuVariants({ size, layout, position, spacing, alignment }), + contextMenuVariants({ + theme, + size, + layout, + position, + spacing, + alignment, + }), className, )} > diff --git a/frontend/src/ui/dropdown/dropdown-menu.tsx b/frontend/src/ui/dropdown/dropdown-menu.tsx index 7880089566..80bf7081a2 100644 --- a/frontend/src/ui/dropdown/dropdown-menu.tsx +++ b/frontend/src/ui/dropdown/dropdown-menu.tsx @@ -27,7 +27,7 @@ export function DropdownMenu({
void; testId?: string; + className?: string; } export function Dropdown({ @@ -30,6 +31,7 @@ export function Dropdown({ defaultValue, onChange, testId, + className, }: DropdownProps) { const [inputValue, setInputValue] = useState(defaultValue?.label ?? ""); const [searchTerm, setSearchTerm] = useState(""); @@ -98,6 +100,7 @@ export function Dropdown({ "bg-tertiary border border-[#717888] rounded w-full p-2", "flex items-center gap-2", isDisabled && "cursor-not-allowed opacity-60", + className, )} > export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING"); export const ENABLE_SANDBOX_GROUPING = () => loadFeatureFlag("SANDBOX_GROUPING"); -export const PROJ_USER_JOURNEY = () => loadFeatureFlag("PROJ_USER_JOURNEY"); +export const ENABLE_PROJ_USER_JOURNEY = () => + loadFeatureFlag("PROJ_USER_JOURNEY"); From af1fa8961af6501c2734a3bb559649c2ed4a9a04 Mon Sep 17 00:00:00 2001 From: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:14:59 -0700 Subject: [PATCH 49/92] feat(frontend): login page cta (#13337) Co-authored-by: openhands --- .../features/auth/login-content.test.tsx | 67 +++++ .../features/auth/login-cta.test.tsx | 63 +++++ frontend/__tests__/routes/login.test.tsx | 5 + .../features/auth/login-content.tsx | 231 +++++++++--------- .../components/features/auth/login-cta.tsx | 66 +++++ frontend/src/i18n/declaration.ts | 6 + frontend/src/i18n/translation.json | 96 ++++++++ frontend/src/tailwind.css | 2 +- 8 files changed, 425 insertions(+), 111 deletions(-) create mode 100644 frontend/__tests__/components/features/auth/login-cta.test.tsx create mode 100644 frontend/src/components/features/auth/login-cta.tsx diff --git a/frontend/__tests__/components/features/auth/login-content.test.tsx b/frontend/__tests__/components/features/auth/login-content.test.tsx index a331ee2378..176b15ea95 100644 --- a/frontend/__tests__/components/features/auth/login-content.test.tsx +++ b/frontend/__tests__/components/features/auth/login-content.test.tsx @@ -49,9 +49,17 @@ vi.mock("#/utils/custom-toast-handlers", () => ({ displayErrorToast: vi.fn(), })); +// Mock feature flags - we'll control the return value in each test +const mockEnableProjUserJourney = vi.fn(() => true); +vi.mock("#/utils/feature-flags", () => ({ + ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(), +})); + describe("LoginContent", () => { beforeEach(() => { vi.stubGlobal("location", { href: "" }); + // Reset mock to return true by default + mockEnableProjUserJourney.mockReturnValue(true); }); afterEach(() => { @@ -274,6 +282,65 @@ describe("LoginContent", () => { expect(screen.getByTestId("terms-and-privacy-notice")).toBeInTheDocument(); }); + it("should display the enterprise LoginCTA component when appMode is saas and feature flag enabled", () => { + render( + + + , + ); + + expect(screen.getByTestId("login-cta")).toBeInTheDocument(); + }); + + it("should not display the enterprise LoginCTA component when appMode is oss even with feature flag enabled", () => { + render( + + + , + ); + + expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument(); + }); + + it("should not display the enterprise LoginCTA component when appMode is null", () => { + render( + + + , + ); + + expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument(); + }); + + it("should not display the enterprise LoginCTA component when feature flag is disabled", () => { + // Disable the feature flag + mockEnableProjUserJourney.mockReturnValue(false); + + render( + + + , + ); + + expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument(); + }); + it("should display invitation pending message when hasInvitation is true", () => { render( diff --git a/frontend/__tests__/components/features/auth/login-cta.test.tsx b/frontend/__tests__/components/features/auth/login-cta.test.tsx new file mode 100644 index 0000000000..1a17d6d285 --- /dev/null +++ b/frontend/__tests__/components/features/auth/login-cta.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { LoginCTA } from "#/components/features/auth/login-cta"; + +// Mock useTracking hook +const mockTrackSaasSelfhostedInquiry = vi.fn(); +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry, + }), +})); + +describe("LoginCTA", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render enterprise CTA with title and description", () => { + render(); + + expect(screen.getByTestId("login-cta")).toBeInTheDocument(); + expect(screen.getByText("CTA$ENTERPRISE")).toBeInTheDocument(); + expect(screen.getByText("CTA$ENTERPRISE_DEPLOY")).toBeInTheDocument(); + }); + + it("should render all enterprise feature list items", () => { + render(); + + expect(screen.getByText("CTA$FEATURE_ON_PREMISES")).toBeInTheDocument(); + expect(screen.getByText("CTA$FEATURE_DATA_CONTROL")).toBeInTheDocument(); + expect(screen.getByText("CTA$FEATURE_COMPLIANCE")).toBeInTheDocument(); + expect(screen.getByText("CTA$FEATURE_SUPPORT")).toBeInTheDocument(); + }); + + it("should render Learn More as a link with correct href and target", () => { + render(); + + const learnMoreLink = screen.getByRole("link", { + name: "CTA$LEARN_MORE", + }); + expect(learnMoreLink).toHaveAttribute( + "href", + "https://openhands.dev/enterprise/", + ); + expect(learnMoreLink).toHaveAttribute("target", "_blank"); + expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should call trackSaasSelfhostedInquiry with location 'login_page' when Learn More is clicked", async () => { + const user = userEvent.setup(); + render(); + + const learnMoreLink = screen.getByRole("link", { + name: "CTA$LEARN_MORE", + }); + await user.click(learnMoreLink); + + expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({ + location: "login_page", + }); + }); +}); diff --git a/frontend/__tests__/routes/login.test.tsx b/frontend/__tests__/routes/login.test.tsx index 8158413dfc..bc247f9d97 100644 --- a/frontend/__tests__/routes/login.test.tsx +++ b/frontend/__tests__/routes/login.test.tsx @@ -73,6 +73,11 @@ vi.mock("#/hooks/use-invitation", () => ({ useInvitation: () => useInvitationMock(), })); +// Mock feature flags - enable by default for tests +vi.mock("#/utils/feature-flags", () => ({ + ENABLE_PROJ_USER_JOURNEY: () => true, +})); + const RouterStub = createRoutesStub([ { Component: LoginPage, diff --git a/frontend/src/components/features/auth/login-content.tsx b/frontend/src/components/features/auth/login-content.tsx index fbae5df4ea..b317bd5543 100644 --- a/frontend/src/components/features/auth/login-content.tsx +++ b/frontend/src/components/features/auth/login-content.tsx @@ -13,6 +13,9 @@ import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-not import { useRecaptcha } from "#/hooks/use-recaptcha"; import { useConfig } from "#/hooks/query/use-config"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { cn } from "#/utils/utils"; +import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags"; +import { LoginCTA } from "./login-cta"; export interface LoginContentProps { githubAuthUrl: string | null; @@ -177,125 +180,133 @@ export function LoginContent({ return (
-
- -
+
+
+ +
-

- {t(I18nKey.AUTH$LETS_GET_STARTED)} -

+

+ {t(I18nKey.AUTH$LETS_GET_STARTED)} +

+ + {shouldShownHelperText && ( +
+ {emailVerified && ( +

+ {t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)} +

+ )} + {hasDuplicatedEmail && ( +

+ {t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)} +

+ )} + {recaptchaBlocked && ( +

+ {t(I18nKey.AUTH$RECAPTCHA_BLOCKED)} +

+ )} + {hasInvitation && ( +

+ {t(I18nKey.AUTH$INVITATION_PENDING)} +

+ )} + {showBitbucket && ( +

+ {t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)} +

+ )} +
+ )} - {shouldShownHelperText && (
- {emailVerified && ( -

- {t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)} -

- )} - {hasDuplicatedEmail && ( -

- {t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)} -

- )} - {recaptchaBlocked && ( -

- {t(I18nKey.AUTH$RECAPTCHA_BLOCKED)} -

- )} - {hasInvitation && ( -

- {t(I18nKey.AUTH$INVITATION_PENDING)} -

- )} - {showBitbucket && ( -

- {t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)} -

+ {noProvidersConfigured ? ( +
+ {t(I18nKey.AUTH$NO_PROVIDERS_CONFIGURED)} +
+ ) : ( + <> + {showGithub && ( + + )} + + {showGitlab && ( + + )} + + {showBitbucket && ( + + )} + + {showBitbucketDataCenter && ( + + )} + + {showEnterpriseSso && ( + + )} + )}
- )} -
- {noProvidersConfigured ? ( -
- {t(I18nKey.AUTH$NO_PROVIDERS_CONFIGURED)} -
- ) : ( - <> - {showGithub && ( - - )} - - {showGitlab && ( - - )} - - {showBitbucket && ( - - )} - - {showBitbucketDataCenter && ( - - )} - - {showEnterpriseSso && ( - - )} - - )} +
- + {appMode === "saas" && ENABLE_PROJ_USER_JOURNEY() && }
); } diff --git a/frontend/src/components/features/auth/login-cta.tsx b/frontend/src/components/features/auth/login-cta.tsx new file mode 100644 index 0000000000..1d67b9cedb --- /dev/null +++ b/frontend/src/components/features/auth/login-cta.tsx @@ -0,0 +1,66 @@ +import { useTranslation } from "react-i18next"; +import { Card } from "#/ui/card"; +import { CardTitle } from "#/ui/card-title"; +import { Typography } from "#/ui/typography"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import StackedIcon from "#/icons/stacked.svg?react"; +import { useTracking } from "#/hooks/use-tracking"; + +export function LoginCTA() { + const { t } = useTranslation(); + const { trackSaasSelfhostedInquiry } = useTracking(); + + const handleLearnMoreClick = () => { + trackSaasSelfhostedInquiry({ location: "login_page" }); + }; + + return ( + +
+
+ +
+ + {t(I18nKey.CTA$ENTERPRISE)} + + + {t(I18nKey.CTA$ENTERPRISE_DEPLOY)} + + +
    +
  • {t(I18nKey.CTA$FEATURE_ON_PREMISES)}
  • +
  • {t(I18nKey.CTA$FEATURE_DATA_CONTROL)}
  • +
  • {t(I18nKey.CTA$FEATURE_COMPLIANCE)}
  • +
  • {t(I18nKey.CTA$FEATURE_SUPPORT)}
  • +
+ + +
+
+ ); +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 81b1301ace..94f72e7c95 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1141,6 +1141,12 @@ export enum I18nKey { ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON", ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON", ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON", + CTA$ENTERPRISE = "CTA$ENTERPRISE", + CTA$ENTERPRISE_DEPLOY = "CTA$ENTERPRISE_DEPLOY", + CTA$FEATURE_ON_PREMISES = "CTA$FEATURE_ON_PREMISES", + CTA$FEATURE_DATA_CONTROL = "CTA$FEATURE_DATA_CONTROL", + CTA$FEATURE_COMPLIANCE = "CTA$FEATURE_COMPLIANCE", + CTA$FEATURE_SUPPORT = "CTA$FEATURE_SUPPORT", ENTERPRISE$SELF_HOSTED = "ENTERPRISE$SELF_HOSTED", ENTERPRISE$TITLE = "ENTERPRISE$TITLE", ENTERPRISE$DESCRIPTION = "ENTERPRISE$DESCRIPTION", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index da7b4f391f..c712b2a2e1 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -18255,6 +18255,102 @@ "tr": "Bitir", "uk": "Завершити" }, + "CTA$ENTERPRISE": { + "en": "Enterprise", + "ja": "エンタープライズ", + "zh-CN": "企业版", + "zh-TW": "企業版", + "ko-KR": "엔터프라이즈", + "no": "Bedrift", + "ar": "المؤسسات", + "de": "Unternehmen", + "fr": "Entreprise", + "it": "Azienda", + "pt": "Empresarial", + "es": "Empresa", + "tr": "Kurumsal", + "uk": "Підприємство" + }, + "CTA$ENTERPRISE_DEPLOY": { + "en": "Deploy OpenHands on your own infrastructure. Full control over data, compliance, and security.", + "ja": "独自のインフラストラクチャにOpenHandsをデプロイ。データ、コンプライアンス、セキュリティを完全にコントロール。", + "zh-CN": "在您自己的基础设施上部署 OpenHands。完全控制数据、合规性和安全性。", + "zh-TW": "在您自己的基礎設施上部署 OpenHands。完全控制資料、合規性和安全性。", + "ko-KR": "자체 인프라에 OpenHands를 배포하세요. 데이터, 규정 준수 및 보안을 완벽하게 제어합니다.", + "no": "Distribuer OpenHands på din egen infrastruktur. Full kontroll over data, samsvar og sikkerhet.", + "ar": "انشر OpenHands على بنيتك التحتية الخاصة. تحكم كامل في البيانات والامتثال والأمان.", + "de": "Stellen Sie OpenHands auf Ihrer eigenen Infrastruktur bereit. Volle Kontrolle über Daten, Compliance und Sicherheit.", + "fr": "Déployez OpenHands sur votre propre infrastructure. Contrôle total sur les données, la conformité et la sécurité.", + "it": "Distribuisci OpenHands sulla tua infrastruttura. Controllo completo su dati, conformità e sicurezza.", + "pt": "Implante o OpenHands em sua própria infraestrutura. Controle total sobre dados, conformidade e segurança.", + "es": "Implemente OpenHands en su propia infraestructura. Control total sobre datos, cumplimiento y seguridad.", + "tr": "OpenHands'i kendi altyapınızda dağıtın. Veri, uyumluluk ve güvenlik üzerinde tam kontrol.", + "uk": "Розгорніть OpenHands на власній інфраструктурі. Повний контроль над даними, відповідністю та безпекою." + }, + "CTA$FEATURE_ON_PREMISES": { + "en": "On-premises or private cloud", + "ja": "オンプレミスまたはプライベートクラウド", + "zh-CN": "本地部署或私有云", + "zh-TW": "本地部署或私有雲", + "ko-KR": "온프레미스 또는 프라이빗 클라우드", + "no": "Lokalt eller privat sky", + "ar": "محلي أو سحابة خاصة", + "de": "On-Premises oder Private Cloud", + "fr": "Sur site ou cloud privé", + "it": "On-premise o cloud privato", + "pt": "Local ou nuvem privada", + "es": "Local o nube privada", + "tr": "Şirket içi veya özel bulut", + "uk": "Локально або приватна хмара" + }, + "CTA$FEATURE_DATA_CONTROL": { + "en": "Full data control", + "ja": "完全なデータ管理", + "zh-CN": "完全数据控制", + "zh-TW": "完全資料控制", + "ko-KR": "완전한 데이터 제어", + "no": "Full datakontroll", + "ar": "تحكم كامل في البيانات", + "de": "Volle Datenkontrolle", + "fr": "Contrôle total des données", + "it": "Controllo completo dei dati", + "pt": "Controle total de dados", + "es": "Control total de datos", + "tr": "Tam veri kontrolü", + "uk": "Повний контроль даних" + }, + "CTA$FEATURE_COMPLIANCE": { + "en": "Custom compliance requirements", + "ja": "カスタムコンプライアンス要件", + "zh-CN": "自定义合规要求", + "zh-TW": "自訂合規要求", + "ko-KR": "사용자 정의 규정 준수 요구 사항", + "no": "Tilpassede samsvarkrav", + "ar": "متطلبات الامتثال المخصصة", + "de": "Individuelle Compliance-Anforderungen", + "fr": "Exigences de conformité personnalisées", + "it": "Requisiti di conformità personalizzati", + "pt": "Requisitos de conformidade personalizados", + "es": "Requisitos de cumplimiento personalizados", + "tr": "Özel uyumluluk gereksinimleri", + "uk": "Індивідуальні вимоги відповідності" + }, + "CTA$FEATURE_SUPPORT": { + "en": "Dedicated support options", + "ja": "専用サポートオプション", + "zh-CN": "专属支持选项", + "zh-TW": "專屬支援選項", + "ko-KR": "전용 지원 옵션", + "no": "Dedikerte støttealternativer", + "ar": "خيارات دعم مخصصة", + "de": "Dedizierte Supportoptionen", + "fr": "Options de support dédiées", + "it": "Opzioni di supporto dedicate", + "pt": "Opções de suporte dedicado", + "es": "Opciones de soporte dedicado", + "tr": "Özel destek seçenekleri", + "uk": "Виділені варіанти підтримки" + }, "ENTERPRISE$SELF_HOSTED": { "en": "Self-Hosted", "ja": "セルフホスト", diff --git a/frontend/src/tailwind.css b/frontend/src/tailwind.css index 8cbacea7ef..afa6a9aef7 100644 --- a/frontend/src/tailwind.css +++ b/frontend/src/tailwind.css @@ -379,6 +379,6 @@ /* CTA card gradient and shadow */ .cta-card-gradient { - background: radial-gradient(85.36% 123.38% at 50% 0%, rgba(255, 255, 255, 0.14) 0%, rgba(0, 0, 0, 0) 100%); + background: radial-gradient(85.36% 123.38% at 50% 0%, rgba(255, 255, 255, 0.14) 0%, rgba(0, 0, 0, 0) 100%), #000000; box-shadow: 0px 4px 6px -4px rgba(0, 0, 0, 0.1), 0px 10px 15px -3px rgba(0, 0, 0, 0.1); } From d3a8b037f25cc0c488b53ce97072cd33869694fe Mon Sep 17 00:00:00 2001 From: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:44:36 -0700 Subject: [PATCH 50/92] feat(frontend): home page cta (#13339) Co-authored-by: openhands Co-authored-by: hieptl --- .../features/home/homepage-cta.test.tsx | 159 +++++++++++++++ .../__tests__/routes/home-screen.test.tsx | 190 ++++++++++++++++++ .../__tests__/utils/local-storage.test.ts | 140 +++++++++++++ .../components/features/home/homepage-cta.tsx | 75 +++++++ frontend/src/routes/home.tsx | 17 ++ frontend/src/utils/feature-flags.ts | 4 +- frontend/src/utils/local-storage.ts | 23 +++ 7 files changed, 606 insertions(+), 2 deletions(-) create mode 100644 frontend/__tests__/components/features/home/homepage-cta.test.tsx create mode 100644 frontend/__tests__/utils/local-storage.test.ts create mode 100644 frontend/src/components/features/home/homepage-cta.tsx diff --git a/frontend/__tests__/components/features/home/homepage-cta.test.tsx b/frontend/__tests__/components/features/home/homepage-cta.test.tsx new file mode 100644 index 0000000000..f44f6bd93b --- /dev/null +++ b/frontend/__tests__/components/features/home/homepage-cta.test.tsx @@ -0,0 +1,159 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { HomepageCTA } from "#/components/features/home/homepage-cta"; + +// Mock the translation function +vi.mock("react-i18next", async () => { + const actual = await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "CTA$ENTERPRISE_TITLE": "Get OpenHands for Enterprise", + "CTA$ENTERPRISE_DESCRIPTION": + "Cloud allows you to access OpenHands anywhere and coordinate with your team like never before", + "CTA$LEARN_MORE": "Learn More", + }; + return translations[key] || key; + }, + i18n: { language: "en" }, + }), + }; +}); + +// Mock local storage +vi.mock("#/utils/local-storage", () => ({ + setCTADismissed: vi.fn(), +})); + +// Mock useTracking hook +const mockTrackSaasSelfhostedInquiry = vi.fn(); +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry, + }), +})); + +import { setCTADismissed } from "#/utils/local-storage"; + +describe("HomepageCTA", () => { + const mockSetShouldShowCTA = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderHomepageCTA = () => { + return render(); + }; + + describe("rendering", () => { + it("renders the enterprise title", () => { + renderHomepageCTA(); + expect( + screen.getByText("Get OpenHands for Enterprise"), + ).toBeInTheDocument(); + }); + + it("renders the enterprise description", () => { + renderHomepageCTA(); + expect( + screen.getByText(/Cloud allows you to access OpenHands anywhere/), + ).toBeInTheDocument(); + }); + + it("renders the Learn More link", () => { + renderHomepageCTA(); + const link = screen.getByRole("link", { name: "Learn More" }); + expect(link).toBeInTheDocument(); + }); + + it("renders the close button with correct aria-label", () => { + renderHomepageCTA(); + expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); + }); + }); + + describe("close button behavior", () => { + it("calls setCTADismissed with 'homepage' when close button is clicked", async () => { + const user = userEvent.setup(); + renderHomepageCTA(); + + const closeButton = screen.getByRole("button", { name: "Close" }); + await user.click(closeButton); + + expect(setCTADismissed).toHaveBeenCalledWith("homepage"); + }); + + it("calls setShouldShowCTA with false when close button is clicked", async () => { + const user = userEvent.setup(); + renderHomepageCTA(); + + const closeButton = screen.getByRole("button", { name: "Close" }); + await user.click(closeButton); + + expect(mockSetShouldShowCTA).toHaveBeenCalledWith(false); + }); + + it("calls both setCTADismissed and setShouldShowCTA in order", async () => { + const user = userEvent.setup(); + const callOrder: string[] = []; + + vi.mocked(setCTADismissed).mockImplementation(() => { + callOrder.push("setCTADismissed"); + }); + mockSetShouldShowCTA.mockImplementation(() => { + callOrder.push("setShouldShowCTA"); + }); + + renderHomepageCTA(); + + const closeButton = screen.getByRole("button", { name: "Close" }); + await user.click(closeButton); + + expect(callOrder).toEqual(["setCTADismissed", "setShouldShowCTA"]); + }); + }); + + describe("Learn More link behavior", () => { + it("calls trackSaasSelfhostedInquiry with location 'home_page' when clicked", async () => { + const user = userEvent.setup(); + renderHomepageCTA(); + + const learnMoreLink = screen.getByRole("link", { name: "Learn More" }); + await user.click(learnMoreLink); + + expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({ + location: "home_page", + }); + }); + + it("has correct href and target attributes", () => { + renderHomepageCTA(); + + const learnMoreLink = screen.getByRole("link", { name: "Learn More" }); + expect(learnMoreLink).toHaveAttribute( + "href", + "https://openhands.dev/enterprise/", + ); + expect(learnMoreLink).toHaveAttribute("target", "_blank"); + expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + + describe("accessibility", () => { + it("close button is focusable", () => { + renderHomepageCTA(); + const closeButton = screen.getByRole("button", { name: "Close" }); + expect(closeButton).not.toHaveAttribute("tabindex", "-1"); + }); + + it("Learn More link is focusable", () => { + renderHomepageCTA(); + const learnMoreLink = screen.getByRole("link", { name: "Learn More" }); + expect(learnMoreLink).not.toHaveAttribute("tabindex", "-1"); + }); + }); +}); diff --git a/frontend/__tests__/routes/home-screen.test.tsx b/frontend/__tests__/routes/home-screen.test.tsx index 27d6825820..05049121fa 100644 --- a/frontend/__tests__/routes/home-screen.test.tsx +++ b/frontend/__tests__/routes/home-screen.test.tsx @@ -609,3 +609,193 @@ describe("New user welcome toast", () => { ).not.toBeInTheDocument(); }); }); + +describe("HomepageCTA visibility", () => { + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(AuthService, "authenticate").mockResolvedValue(true); + + getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS); + + // Mock localStorage to enable the PROJ_USER_JOURNEY feature flag (CTA dismissal also uses localStorage) + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => { + if (key === "FEATURE_PROJ_USER_JOURNEY") { + return "true"; + } + return null; + }), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should show HomepageCTA in SaaS mode when not dismissed and feature flag enabled", async () => { + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + isFetching: false, + isError: false, + }); + useConfigMock.mockReturnValue({ + data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS }, + isLoading: false, + }); + + getConfigSpy.mockResolvedValue({ + app_mode: "saas", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: DEFAULT_FEATURE_FLAGS, + maintenance_start_time: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, + }); + + renderHomeScreen(); + + await screen.findByTestId("home-screen"); + + const ctaLink = await screen.findByRole("link", { + name: "CTA$LEARN_MORE", + }); + expect(ctaLink).toBeInTheDocument(); + }); + + it("should not show HomepageCTA in OSS mode even with feature flag enabled", async () => { + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + isFetching: false, + isError: false, + }); + useConfigMock.mockReturnValue({ + data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS }, + isLoading: false, + }); + + getConfigSpy.mockResolvedValue({ + app_mode: "oss", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: DEFAULT_FEATURE_FLAGS, + maintenance_start_time: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, + }); + + renderHomeScreen(); + + await screen.findByTestId("home-screen"); + + expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument(); + }); + + it("should not show HomepageCTA when feature flag is disabled", async () => { + // Override localStorage to disable the feature flag + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), // No feature flags set + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + isFetching: false, + isError: false, + }); + useConfigMock.mockReturnValue({ + data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS }, + isLoading: false, + }); + + getConfigSpy.mockResolvedValue({ + app_mode: "saas", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: DEFAULT_FEATURE_FLAGS, + maintenance_start_time: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, + }); + + renderHomeScreen(); + + await screen.findByTestId("home-screen"); + + expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument(); + }); + + it("should not show HomepageCTA when dismissed in local storage", async () => { + // Override localStorage to mark CTA as dismissed while keeping the feature flag enabled + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => { + if (key === "FEATURE_PROJ_USER_JOURNEY") { + return "true"; + } + if (key === "homepage-cta-dismissed") { + return "true"; + } + return null; + }), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + isFetching: false, + isError: false, + }); + useConfigMock.mockReturnValue({ + data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS }, + isLoading: false, + }); + + getConfigSpy.mockResolvedValue({ + app_mode: "saas", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: DEFAULT_FEATURE_FLAGS, + maintenance_start_time: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, + }); + + renderHomeScreen(); + + await screen.findByTestId("home-screen"); + + expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/utils/local-storage.test.ts b/frontend/__tests__/utils/local-storage.test.ts new file mode 100644 index 0000000000..69be6899bc --- /dev/null +++ b/frontend/__tests__/utils/local-storage.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + LOCAL_STORAGE_KEYS, + LoginMethod, + setLoginMethod, + getLoginMethod, + clearLoginData, + setCTADismissed, + isCTADismissed, +} from "#/utils/local-storage"; + +describe("local-storage utilities", () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe("Login method utilities", () => { + describe("setLoginMethod", () => { + it("stores the login method in local storage", () => { + setLoginMethod(LoginMethod.GITHUB); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("github"); + }); + + it("stores different login methods correctly", () => { + setLoginMethod(LoginMethod.GITLAB); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab"); + + setLoginMethod(LoginMethod.BITBUCKET); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket"); + + setLoginMethod(LoginMethod.AZURE_DEVOPS); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("azure_devops"); + + setLoginMethod(LoginMethod.ENTERPRISE_SSO); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("enterprise_sso"); + + setLoginMethod(LoginMethod.BITBUCKET_DATA_CENTER); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket_data_center"); + }); + + it("overwrites previous login method", () => { + setLoginMethod(LoginMethod.GITHUB); + setLoginMethod(LoginMethod.GITLAB); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab"); + }); + }); + + describe("getLoginMethod", () => { + it("returns null when no login method is set", () => { + expect(getLoginMethod()).toBeNull(); + }); + + it("returns the stored login method", () => { + localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "github"); + expect(getLoginMethod()).toBe(LoginMethod.GITHUB); + }); + + it("returns correct login method for all types", () => { + localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "gitlab"); + expect(getLoginMethod()).toBe(LoginMethod.GITLAB); + + localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "bitbucket"); + expect(getLoginMethod()).toBe(LoginMethod.BITBUCKET); + + localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "azure_devops"); + expect(getLoginMethod()).toBe(LoginMethod.AZURE_DEVOPS); + }); + }); + + describe("clearLoginData", () => { + it("removes the login method from local storage", () => { + setLoginMethod(LoginMethod.GITHUB); + expect(getLoginMethod()).toBe(LoginMethod.GITHUB); + + clearLoginData(); + expect(getLoginMethod()).toBeNull(); + }); + + it("does not throw when no login method is set", () => { + expect(() => clearLoginData()).not.toThrow(); + }); + }); + }); + + describe("CTA utilities", () => { + describe("isCTADismissed", () => { + it("returns false when CTA has not been dismissed", () => { + expect(isCTADismissed("homepage")).toBe(false); + }); + + it("returns true when CTA has been dismissed", () => { + localStorage.setItem("homepage-cta-dismissed", "true"); + expect(isCTADismissed("homepage")).toBe(true); + }); + + it("returns false when storage value is not 'true'", () => { + localStorage.setItem("homepage-cta-dismissed", "false"); + expect(isCTADismissed("homepage")).toBe(false); + + localStorage.setItem("homepage-cta-dismissed", "invalid"); + expect(isCTADismissed("homepage")).toBe(false); + }); + }); + + describe("setCTADismissed", () => { + it("sets the CTA as dismissed in local storage", () => { + setCTADismissed("homepage"); + expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true"); + }); + + it("generates correct key for homepage location", () => { + setCTADismissed("homepage"); + expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true"); + }); + }); + + describe("storage key format", () => { + it("uses the correct key format: {location}-cta-dismissed", () => { + setCTADismissed("homepage"); + + // Verify key exists with correct format + expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true"); + + // Verify other keys don't exist + expect(localStorage.getItem("cta-dismissed")).toBeNull(); + expect(localStorage.getItem("homepage")).toBeNull(); + }); + }); + + describe("persistence", () => { + it("dismissed state persists across multiple reads", () => { + setCTADismissed("homepage"); + + expect(isCTADismissed("homepage")).toBe(true); + expect(isCTADismissed("homepage")).toBe(true); + expect(isCTADismissed("homepage")).toBe(true); + }); + }); + }); +}); diff --git a/frontend/src/components/features/home/homepage-cta.tsx b/frontend/src/components/features/home/homepage-cta.tsx new file mode 100644 index 0000000000..af55498550 --- /dev/null +++ b/frontend/src/components/features/home/homepage-cta.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from "react-i18next"; +import { Dispatch, SetStateAction } from "react"; +import { Card } from "#/ui/card"; +import { CardTitle } from "#/ui/card-title"; +import { Typography } from "#/ui/typography"; +import { cn } from "#/utils/utils"; +import { I18nKey } from "#/i18n/declaration"; +import { setCTADismissed } from "#/utils/local-storage"; +import { useTracking } from "#/hooks/use-tracking"; +import CloseIcon from "#/icons/close.svg?react"; + +interface HomepageCTAProps { + setShouldShowCTA: Dispatch>; +} + +export function HomepageCTA({ setShouldShowCTA }: HomepageCTAProps) { + const { t } = useTranslation(); + const { trackSaasSelfhostedInquiry } = useTracking(); + + const handleClose = () => { + setCTADismissed("homepage"); + setShouldShowCTA(false); + }; + + const handleLearnMoreClick = () => { + trackSaasSelfhostedInquiry({ location: "home_page" }); + }; + + return ( + + + +
+
+ + {t(I18nKey.CTA$ENTERPRISE_TITLE)} + + + + {t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)} + +
+ + + {t(I18nKey.CTA$LEARN_MORE)} + +
+
+ ); +} diff --git a/frontend/src/routes/home.tsx b/frontend/src/routes/home.tsx index 5c3679414c..0d6069d62b 100644 --- a/frontend/src/routes/home.tsx +++ b/frontend/src/routes/home.tsx @@ -6,14 +6,25 @@ import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestio import { GitRepository } from "#/types/git"; import { NewConversation } from "#/components/features/home/new-conversation/new-conversation"; import { RecentConversations } from "#/components/features/home/recent-conversations/recent-conversations"; +import { HomepageCTA } from "#/components/features/home/homepage-cta"; +import { isCTADismissed } from "#/utils/local-storage"; +import { useConfig } from "#/hooks/query/use-config"; +import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags"; ; function HomeScreen() { + const { data: config } = useConfig(); const [selectedRepo, setSelectedRepo] = React.useState( null, ); + const [shouldShowCTA, setShouldShowCTA] = React.useState( + () => !isCTADismissed("homepage"), + ); + + const isSaasMode = config?.app_mode === "saas"; + return (
+ + {isSaasMode && shouldShowCTA && ENABLE_PROJ_USER_JOURNEY() && ( +
+ +
+ )}
); } diff --git a/frontend/src/utils/feature-flags.ts b/frontend/src/utils/feature-flags.ts index 5c4b4ce0cf..442acba50d 100644 --- a/frontend/src/utils/feature-flags.ts +++ b/frontend/src/utils/feature-flags.ts @@ -18,7 +18,7 @@ export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB"); export const ENABLE_TRAJECTORY_REPLAY = () => loadFeatureFlag("TRAJECTORY_REPLAY"); export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING"); -export const ENABLE_SANDBOX_GROUPING = () => - loadFeatureFlag("SANDBOX_GROUPING"); export const ENABLE_PROJ_USER_JOURNEY = () => loadFeatureFlag("PROJ_USER_JOURNEY"); +export const ENABLE_SANDBOX_GROUPING = () => + loadFeatureFlag("SANDBOX_GROUPING"); diff --git a/frontend/src/utils/local-storage.ts b/frontend/src/utils/local-storage.ts index 919d97225e..a942e6fab5 100644 --- a/frontend/src/utils/local-storage.ts +++ b/frontend/src/utils/local-storage.ts @@ -36,3 +36,26 @@ export const getLoginMethod = (): LoginMethod | null => { export const clearLoginData = (): void => { localStorage.removeItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD); }; + +// CTA locations that can be dismissed +export type CTALocation = "homepage"; + +// Generate storage key for a CTA location +const getCTAKey = (location: CTALocation): string => + `${location}-cta-dismissed`; + +/** + * Set a CTA as dismissed in local storage (persists across tabs) + * @param location The CTA location to dismiss + */ +export const setCTADismissed = (location: CTALocation): void => { + localStorage.setItem(getCTAKey(location), "true"); +}; + +/** + * Check if a CTA has been dismissed + * @param location The CTA location to check + * @returns true if dismissed, false otherwise + */ +export const isCTADismissed = (location: CTALocation): boolean => + localStorage.getItem(getCTAKey(location)) === "true"; From 26fa1185a47e82b7c5e5d36f0a5cf55e88d6ff46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:44:35 -0500 Subject: [PATCH 51/92] chore(deps): bump mcp from 1.25.0 to 1.26.0 in the mcp-packages group (#13314) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: openhands Co-authored-by: aivong-openhands --- enterprise/poetry.lock | 6 +++--- poetry.lock | 6 +++--- uv.lock | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index 395c14bd12..589be34bb0 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -5443,14 +5443,14 @@ files = [ [[package]] name = "mcp" -version = "1.25.0" +version = "1.26.0" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"}, - {file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"}, + {file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"}, + {file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"}, ] [package.dependencies] diff --git a/poetry.lock b/poetry.lock index 82082a80c4..5b0b30f61d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -5496,14 +5496,14 @@ files = [ [[package]] name = "mcp" -version = "1.25.0" +version = "1.26.0" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"}, - {file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"}, + {file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"}, + {file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"}, ] [package.dependencies] diff --git a/uv.lock b/uv.lock index 34e19fdd73..aec35e87db 100644 --- a/uv.lock +++ b/uv.lock @@ -3152,7 +3152,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.25.0" +version = "1.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3170,9 +3170,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] [[package]] From 28ecf0640425a4e27e1fde7d6b7b863a3e70de51 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Wed, 18 Mar 2026 10:52:05 +0000 Subject: [PATCH 52/92] Render V1 paired tool summaries (#13451) Co-authored-by: openhands --- .../components/v1/get-event-content.test.tsx | 95 +++++++++++++++++++ .../get-action-content.ts | 4 - .../get-event-content.tsx | 33 ++++++- .../generic-event-message-wrapper.tsx | 6 +- .../src/components/v1/chat/event-message.tsx | 5 + frontend/src/routes/shared-conversation.tsx | 12 ++- .../src/types/v1/core/events/action-event.ts | 5 + 7 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 frontend/__tests__/components/v1/get-event-content.test.tsx diff --git a/frontend/__tests__/components/v1/get-event-content.test.tsx b/frontend/__tests__/components/v1/get-event-content.test.tsx new file mode 100644 index 0000000000..45d6512fbd --- /dev/null +++ b/frontend/__tests__/components/v1/get-event-content.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { getEventContent } from "#/components/v1/chat"; +import { ActionEvent, ObservationEvent, SecurityRisk } from "#/types/v1/core"; + +const terminalActionEvent: ActionEvent = { + id: "action-1", + timestamp: new Date().toISOString(), + source: "agent", + thought: [{ type: "text", text: "Checking repository status." }], + thinking_blocks: [], + action: { + kind: "TerminalAction", + command: "git status", + is_input: false, + timeout: null, + reset: false, + }, + tool_name: "terminal", + tool_call_id: "tool-1", + tool_call: { + id: "tool-1", + type: "function", + function: { + name: "terminal", + arguments: '{"command":"git status"}', + }, + }, + llm_response_id: "response-1", + security_risk: SecurityRisk.LOW, + summary: "Check repository status", +}; + +const terminalObservationEvent: ObservationEvent = { + id: "obs-1", + timestamp: new Date().toISOString(), + source: "environment", + tool_name: "terminal", + tool_call_id: "tool-1", + action_id: "action-1", + observation: { + kind: "TerminalObservation", + content: [{ type: "text", text: "On branch main" }], + command: "git status", + exit_code: 0, + is_error: false, + timeout: false, + metadata: { + exit_code: 0, + pid: 1, + username: "openhands", + hostname: "sandbox", + prefix: "", + suffix: "", + working_dir: "/workspace/project/OpenHands", + py_interpreter_path: null, + }, + }, +}; + +describe("getEventContent", () => { + it("uses the action summary as the full action title", () => { + const { title } = getEventContent(terminalActionEvent); + + render(<>{title}); + + expect(screen.getByText("Check repository status")).toBeInTheDocument(); + expect(screen.queryByText("$ git status")).not.toBeInTheDocument(); + }); + + it("falls back to command-based title when summary is missing", () => { + const actionWithoutSummary = { ...terminalActionEvent, summary: undefined }; + const { title } = getEventContent(actionWithoutSummary); + + render(<>{title}); + + // Without i18n loaded, the translation key renders as the raw key + expect(screen.getByText("ACTION_MESSAGE$RUN")).toBeInTheDocument(); + expect( + screen.queryByText("Check repository status"), + ).not.toBeInTheDocument(); + }); + + it("reuses the action summary as the full paired observation title", () => { + const { title } = getEventContent( + terminalObservationEvent, + terminalActionEvent, + ); + + render(<>{title}); + + expect(screen.getByText("Check repository status")).toBeInTheDocument(); + expect(screen.queryByText("$ git status")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts index 62e907de27..c0f2bf9db8 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/get-action-content.ts @@ -56,10 +56,6 @@ const getSearchActionContent = ( if ("include" in action && action.include) { parts.push(`**Include:** \`${action.include}\``); } - const { summary } = event as { summary?: string }; - if (summary) { - parts.push(`**Summary:** ${summary}`); - } return parts.length > 0 ? parts.join("\n") : getNoContentActionContent(); }; diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx index 55e9e939db..6e6e847639 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx +++ b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx @@ -1,6 +1,6 @@ import { Trans } from "react-i18next"; import React from "react"; -import { OpenHandsEvent, ObservationEvent } from "#/types/v1/core"; +import { OpenHandsEvent, ObservationEvent, ActionEvent } from "#/types/v1/core"; import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards"; import { MonoComponent } from "../../../features/chat/mono-component"; import { PathComponent } from "../../../features/chat/path-component"; @@ -37,6 +37,13 @@ const createTitleFromKey = ( ); }; +const getSummaryTitleForActionEvent = ( + event: ActionEvent, +): React.ReactNode | null => { + const summary = event.summary?.trim().replace(/\s+/g, " ") || ""; + return summary || null; +}; + // Action Event Processing const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => { // Early return if not an action event @@ -44,6 +51,11 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => { return ""; } + const summaryTitle = getSummaryTitleForActionEvent(event); + if (summaryTitle) { + return summaryTitle; + } + const actionType = event.action.kind; let actionKey = ""; let actionValues: Record = {}; @@ -127,12 +139,22 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => { }; // Observation Event Processing -const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => { +const getObservationEventTitle = ( + event: OpenHandsEvent, + correspondingAction?: ActionEvent, +): React.ReactNode => { // Early return if not an observation event if (!isObservationEvent(event)) { return ""; } + if (correspondingAction) { + const summaryTitle = getSummaryTitleForActionEvent(correspondingAction); + if (summaryTitle) { + return summaryTitle; + } + } + const observationType = event.observation.kind; let observationKey = ""; let observationValues: Record = {}; @@ -208,7 +230,10 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => { return observationType; }; -export const getEventContent = (event: OpenHandsEvent | SkillReadyEvent) => { +export const getEventContent = ( + event: OpenHandsEvent | SkillReadyEvent, + correspondingAction?: ActionEvent, +) => { let title: React.ReactNode = ""; let details: string | React.ReactNode = ""; @@ -226,7 +251,7 @@ export const getEventContent = (event: OpenHandsEvent | SkillReadyEvent) => { title = getActionEventTitle(event); details = getActionContent(event); } else if (isObservationEvent(event)) { - title = getObservationEventTitle(event); + title = getObservationEventTitle(event, correspondingAction); // For TaskTrackerObservation, use React component instead of markdown if (event.observation.kind === "TaskTrackerObservation") { diff --git a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx index a29d853694..3c2d404bdf 100644 --- a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx +++ b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx @@ -1,4 +1,4 @@ -import { OpenHandsEvent } from "#/types/v1/core"; +import { OpenHandsEvent, ActionEvent } 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"; @@ -13,13 +13,15 @@ import { ObservationResultStatus } from "../../../features/chat/event-content-he interface GenericEventMessageWrapperProps { event: OpenHandsEvent | SkillReadyEvent; isLastMessage: boolean; + correspondingAction?: ActionEvent; } export function GenericEventMessageWrapper({ event, isLastMessage, + correspondingAction, }: GenericEventMessageWrapperProps) { - const { title, details } = getEventContent(event); + const { title, details } = getEventContent(event, correspondingAction); // SkillReadyEvent is not an observation event, so skip the observation checks if (!isSkillReadyEvent(event)) { diff --git a/frontend/src/components/v1/chat/event-message.tsx b/frontend/src/components/v1/chat/event-message.tsx index 57b543bc8e..14ee59df46 100644 --- a/frontend/src/components/v1/chat/event-message.tsx +++ b/frontend/src/components/v1/chat/event-message.tsx @@ -265,6 +265,11 @@ export function EventMessage({ ); diff --git a/frontend/src/routes/shared-conversation.tsx b/frontend/src/routes/shared-conversation.tsx index d51ec2e11a..f21f144226 100644 --- a/frontend/src/routes/shared-conversation.tsx +++ b/frontend/src/routes/shared-conversation.tsx @@ -7,6 +7,8 @@ import { useSharedConversationEvents } from "#/hooks/query/use-shared-conversati import { Messages as V1Messages } from "#/components/v1/chat"; import { shouldRenderEvent } from "#/components/v1/chat/event-content-helpers/should-render-event"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { handleEventForUI } from "#/utils/handle-event-for-ui"; +import { OpenHandsEvent } from "#/types/v1/core"; import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react"; export default function SharedConversation() { @@ -30,9 +32,15 @@ export default function SharedConversation() { // Transform shared events to V1 format const v1Events = eventsData?.items || []; - // Filter events that should be rendered + // Reconstruct the same UI event stream used in live conversations so + // completed tool calls render as a single action/observation unit. const renderableEvents = React.useMemo( - () => v1Events.filter(shouldRenderEvent), + () => + v1Events + .reduce< + OpenHandsEvent[] + >((uiEvents, event) => handleEventForUI(event, uiEvents), []) + .filter(shouldRenderEvent), [v1Events], ); diff --git a/frontend/src/types/v1/core/events/action-event.ts b/frontend/src/types/v1/core/events/action-event.ts index 33d7ce647c..fd2408b5d6 100644 --- a/frontend/src/types/v1/core/events/action-event.ts +++ b/frontend/src/types/v1/core/events/action-event.ts @@ -58,4 +58,9 @@ export interface ActionEvent extends BaseEvent { * The LLM's assessment of the safety risk of this action */ security_risk: SecurityRisk; + + /** + * Optional LLM-generated summary used to label the tool call in the UI. + */ + summary?: string | null; } From fe4c0569f7ec8ea2cfb87dfee892e6be9f5d2840 Mon Sep 17 00:00:00 2001 From: Chris Bagwell Date: Wed, 18 Mar 2026 09:57:23 -0500 Subject: [PATCH 53/92] Remove unused WORK_HOSTS_SKILL_FOOTER (#12594) --- .../app_server/app_conversation/skill_loader.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/openhands/app_server/app_conversation/skill_loader.py b/openhands/app_server/app_conversation/skill_loader.py index 3638c6e681..d5976aedac 100644 --- a/openhands/app_server/app_conversation/skill_loader.py +++ b/openhands/app_server/app_conversation/skill_loader.py @@ -33,21 +33,6 @@ class ExposedUrlConfig(BaseModel): port: int -WORK_HOSTS_SKILL_FOOTER = """ -When starting a web server, use the corresponding ports via environment variables: -- $WORKER_1 for the first port -- $WORKER_2 for the second port - -**CRITICAL: You MUST enable CORS and bind to 0.0.0.0.** Without CORS headers, the App tab cannot detect your server and will show an empty state. - -Example (Flask): -```python -from flask_cors import CORS -CORS(app) -app.run(host='0.0.0.0', port=int(os.environ.get('WORKER_1', 12000))) -```""" - - class SandboxConfig(BaseModel): """Sandbox configuration for agent-server API request.""" From 6589e592e371a9320abd9f8da1c1c8dbde3078ab Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:50:16 +0700 Subject: [PATCH 54/92] feat(frontend): add contextual info messages on LLM settings page (org project) (#13460) --- .../__tests__/routes/llm-settings.test.tsx | 194 +++++++++++++++++- frontend/src/i18n/declaration.ts | 2 + frontend/src/i18n/translation.json | 32 +++ frontend/src/routes/llm-settings.tsx | 32 +++ 4 files changed, 259 insertions(+), 1 deletion(-) diff --git a/frontend/__tests__/routes/llm-settings.test.tsx b/frontend/__tests__/routes/llm-settings.test.tsx index 2dabd4da79..615ad9396c 100644 --- a/frontend/__tests__/routes/llm-settings.test.tsx +++ b/frontend/__tests__/routes/llm-settings.test.tsx @@ -13,7 +13,39 @@ import * as ToastHandlers from "#/utils/custom-toast-handlers"; import OptionService from "#/api/option-service/option-service.api"; import { organizationService } from "#/api/organization-service/organization-service.api"; import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; -import type { OrganizationMember } from "#/types/org"; +import type { Organization, OrganizationMember } from "#/types/org"; + +/** Creates a mock Organization with default values for testing */ +const createMockOrganization = ( + overrides: Partial & Pick, +): Organization => ({ + contact_name: "", + contact_email: "", + conversation_expiration: 0, + agent: "CodeActAgent", + default_max_iterations: 20, + security_analyzer: "", + confirmation_mode: false, + default_llm_model: "", + default_llm_api_key_for_byor: "", + default_llm_base_url: "", + remote_runtime_resource_factor: 1, + enable_default_condenser: true, + billing_margin: 0, + enable_proactive_conversation_starters: false, + sandbox_base_container_image: "", + sandbox_runtime_container_image: "", + org_version: 1, + mcp_config: { tools: [], settings: {} }, + search_api_key: null, + sandbox_api_key: null, + max_budget_per_task: 0, + enable_solvability_analysis: false, + v1_enabled: true, + credits: 0, + is_personal: false, + ...overrides, +}); // Mock react-router hooks const mockUseSearchParams = vi.fn(); @@ -1767,3 +1799,163 @@ describe("clientLoader permission checks", () => { expect(typeof clientLoader).toBe("function"); }); }); + +describe("Contextual info messages", () => { + it("should show admin message when user is an admin in a team organization", async () => { + // Arrange + const orgId = "team-org-1"; + const adminMeData: OrganizationMember = { + org_id: orgId, + user_id: "1", + email: "admin@example.com", + role: "admin", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }; + + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, + }); + + vi.spyOn(organizationService, "getMe").mockResolvedValue(adminMeData); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [ + createMockOrganization({ + id: orgId, + name: "Team Org", + is_personal: false, + }), + ], + currentOrgId: orgId, + }); + + // Act + renderLlmSettingsScreen(orgId, adminMeData); + + // Assert + await waitFor(() => { + expect( + screen.getByTestId("llm-settings-info-message"), + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId("llm-settings-info-message")).toHaveTextContent( + "SETTINGS$LLM_ADMIN_INFO", + ); + }); + + it("should show member message when user is a member in a team organization", async () => { + // Arrange + const orgId = "team-org-2"; + const memberMeData: OrganizationMember = { + org_id: orgId, + user_id: "2", + email: "member@example.com", + role: "member", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }; + + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, + }); + + vi.spyOn(organizationService, "getMe").mockResolvedValue(memberMeData); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [ + createMockOrganization({ + id: orgId, + name: "Team Org", + is_personal: false, + }), + ], + currentOrgId: orgId, + }); + + // Act + renderLlmSettingsScreen(orgId, memberMeData); + + // Assert + await waitFor(() => { + expect( + screen.getByTestId("llm-settings-info-message"), + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId("llm-settings-info-message")).toHaveTextContent( + "SETTINGS$LLM_MEMBER_INFO", + ); + }); + + it("should not show info message in personal workspace", async () => { + // Arrange + const orgId = "personal-org-1"; + const ownerMeData: OrganizationMember = { + org_id: orgId, + user_id: "3", + email: "user@example.com", + role: "owner", + status: "active", + llm_api_key: "", + max_iterations: 20, + llm_model: "", + llm_api_key_for_byor: null, + llm_base_url: "", + }; + + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, + }); + + vi.spyOn(organizationService, "getMe").mockResolvedValue(ownerMeData); + vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ + items: [ + createMockOrganization({ id: orgId, name: "Personal", is_personal: true }), + ], + currentOrgId: orgId, + }); + + // Act + renderLlmSettingsScreen(orgId, ownerMeData); + + // Assert + await waitFor(() => { + expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId("llm-settings-info-message"), + ).not.toBeInTheDocument(); + }); + + it("should not show info message in OSS mode", async () => { + // Arrange + mockUseConfig.mockReturnValue({ + data: { app_mode: "oss" }, + isLoading: false, + }); + + // Act + renderLlmSettingsScreen(); + + // Assert + await waitFor(() => { + expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId("llm-settings-info-message"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 94f72e7c95..a66552ff3c 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -434,6 +434,8 @@ export enum I18nKey { SETTINGS$OPENHANDS_API_KEY_HELP_TEXT = "SETTINGS$OPENHANDS_API_KEY_HELP_TEXT", SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX = "SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX", SETTINGS$LLM_BILLING_INFO = "SETTINGS$LLM_BILLING_INFO", + SETTINGS$LLM_ADMIN_INFO = "SETTINGS$LLM_ADMIN_INFO", + SETTINGS$LLM_MEMBER_INFO = "SETTINGS$LLM_MEMBER_INFO", SETTINGS$SEE_PRICING_DETAILS = "SETTINGS$SEE_PRICING_DETAILS", SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY", SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index c712b2a2e1..515a7e11b5 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -6943,6 +6943,38 @@ "de": "LLM-Nutzung wird zu Anbieterpreisen ohne Aufschlag abgerechnet.", "uk": "Використання LLM оплачується за тарифами провайдерів без надбавки." }, + "SETTINGS$LLM_ADMIN_INFO": { + "en": "The LLM settings configured below will apply to all users of the organization.", + "ja": "以下に設定されたLLM設定は、組織のすべてのユーザーに適用されます。", + "zh-CN": "以下配置的LLM设置将应用于组织的所有用户。", + "zh-TW": "以下配置的LLM設定將適用於組織的所有用戶。", + "ko-KR": "아래에 구성된 LLM 설정은 조직의 모든 사용자에게 적용됩니다.", + "no": "LLM-innstillingene som er konfigurert nedenfor, vil gjelde for alle brukere i organisasjonen.", + "it": "Le impostazioni LLM configurate di seguito si applicheranno a tutti gli utenti dell'organizzazione.", + "pt": "As configurações de LLM configuradas abaixo serão aplicadas a todos os usuários da organização.", + "es": "La configuración de LLM configurada a continuación se aplicará a todos los usuarios de la organización.", + "ar": "ستنطبق إعدادات LLM المكونة أدناه على جميع مستخدمي المؤسسة.", + "fr": "Les paramètres LLM configurés ci-dessous s'appliqueront à tous les utilisateurs de l'organisation.", + "tr": "Aşağıda yapılandırılan LLM ayarları, kuruluştaki tüm kullanıcılara uygulanacaktır.", + "de": "Die unten konfigurierten LLM-Einstellungen gelten für alle Benutzer der Organisation.", + "uk": "Налаштування LLM, налаштовані нижче, будуть застосовані до всіх користувачів організації." + }, + "SETTINGS$LLM_MEMBER_INFO": { + "en": "LLM settings are managed by your organization's administrator.", + "ja": "LLM設定は組織の管理者によって管理されています。", + "zh-CN": "LLM设置由您的组织管理员管理。", + "zh-TW": "LLM設定由您的組織管理員管理。", + "ko-KR": "LLM 설정은 조직 관리자가 관리합니다.", + "no": "LLM-innstillinger administreres av organisasjonens administrator.", + "it": "Le impostazioni LLM sono gestite dall'amministratore della tua organizzazione.", + "pt": "As configurações de LLM são gerenciadas pelo administrador da sua organização.", + "es": "La configuración de LLM es administrada por el administrador de su organización.", + "ar": "يتم إدارة إعدادات LLM بواسطة مسؤول مؤسستك.", + "fr": "Les paramètres LLM sont gérés par l'administrateur de votre organisation.", + "tr": "LLM ayarları kuruluşunuzun yöneticisi tarafından yönetilmektedir.", + "de": "LLM-Einstellungen werden vom Administrator Ihrer Organisation verwaltet.", + "uk": "Налаштування LLM керуються адміністратором вашої організації." + }, "SETTINGS$SEE_PRICING_DETAILS": { "en": "See pricing details.", "ja": "価格詳細を見る。", diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index d9489ec35a..b5c394c064 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -31,6 +31,7 @@ import { getProviderId } from "#/utils/map-provider"; import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models"; import { useMe } from "#/hooks/query/use-me"; import { usePermission } from "#/hooks/organizations/use-permissions"; +import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access"; interface OpenHandsApiKeyHelpProps { testId: string; @@ -74,12 +75,35 @@ function LlmSettingsScreen() { const { data: config } = useConfig(); const { data: me } = useMe(); const { hasPermission } = usePermission(me?.role ?? "member"); + const { isPersonalOrg, isTeamOrg } = useOrgTypeAndAccess(); // In OSS mode, user has full access (no permission restrictions) // In SaaS mode, check role-based permissions (members can only view, owners and admins can edit) const isOssMode = config?.app_mode === "oss"; const isReadOnly = isOssMode ? false : !hasPermission("edit_llm_settings"); + // Determine the contextual info message based on workspace type and role + const getLlmSettingsInfoMessage = (): I18nKey | null => { + // No message in OSS mode (no organization context) + if (isOssMode) return null; + + // No message for personal workspaces + if (isPersonalOrg) return null; + + // Team org - show appropriate message based on role + if (isTeamOrg) { + const role = me?.role ?? "member"; + if (role === "admin" || role === "owner") { + return I18nKey.SETTINGS$LLM_ADMIN_INFO; + } + return I18nKey.SETTINGS$LLM_MEMBER_INFO; + } + + return null; + }; + + const llmInfoMessage = getLlmSettingsInfoMessage(); + const [view, setView] = React.useState<"basic" | "advanced">("basic"); const [dirtyInputs, setDirtyInputs] = React.useState({ @@ -504,6 +528,14 @@ function LlmSettingsScreen() { className="flex flex-col h-full justify-between" >
+ {llmInfoMessage && ( +

+ {t(llmInfoMessage)} +

+ )} Date: Wed, 18 Mar 2026 22:50:30 +0700 Subject: [PATCH 55/92] feat(frontend): improve conversation access error message with workspace hint (org project) (#13461) --- frontend/src/i18n/translation.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 515a7e11b5..93ec856439 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -16096,20 +16096,20 @@ "uk": "Не вдалося запустити розмову із завдання." }, "CONVERSATION$NOT_EXIST_OR_NO_PERMISSION": { - "en": "This conversation does not exist, or you do not have permission to access it.", - "ja": "この会話は存在しないか、アクセスする権限がありません。", - "zh-CN": "此对话不存在,或您没有访问权限。", - "zh-TW": "此對話不存在,或您沒有訪問權限。", - "ko-KR": "이 대화가 존재하지 않거나 액세스 권한이 없습니다.", - "no": "Denne samtalen eksisterer ikke, eller du har ikke tillatelse til å få tilgang til den.", - "it": "Questa conversazione non esiste o non hai il permesso di accedervi.", - "pt": "Esta conversa não existe ou você não tem permissão para acessá-la.", - "es": "Esta conversación no existe o no tienes permiso para acceder a ella.", - "ar": "هذه المحادثة غير موجودة أو ليس لديك إذن للوصول إليها.", - "fr": "Cette conversation n'existe pas ou vous n'avez pas la permission d'y accéder.", - "tr": "Bu konuşma mevcut değil veya erişim izniniz yok.", - "de": "Diese Konversation existiert nicht oder Sie haben keine Berechtigung darauf zuzugreifen.", - "uk": "Ця розмова не існує або у вас немає дозволу на доступ до неї." + "en": "This conversation does not exist, or you do not have permission to access it. If this is your conversation, try switching to the workspace where it was created.", + "ja": "この会話は存在しないか、アクセスする権限がありません。あなたの会話であれば、作成したワークスペースに切り替えてみてください。", + "zh-CN": "此对话不存在,或您没有访问权限。如果这是您的对话,请尝试切换到创建该对话的工作区。", + "zh-TW": "此對話不存在,或您沒有訪問權限。如果這是您的對話,請嘗試切換到創建該對話的工作區。", + "ko-KR": "이 대화가 존재하지 않거나 액세스 권한이 없습니다. 본인의 대화인 경우, 대화가 생성된 워크스페이스로 전환해 보세요.", + "no": "Denne samtalen eksisterer ikke, eller du har ikke tillatelse til å få tilgang til den. Hvis dette er din samtale, prøv å bytte til arbeidsområdet der den ble opprettet.", + "it": "Questa conversazione non esiste o non hai il permesso di accedervi. Se è una tua conversazione, prova a passare all'area di lavoro in cui è stata creata.", + "pt": "Esta conversa não existe ou você não tem permissão para acessá-la. Se esta é sua conversa, tente mudar para o workspace onde ela foi criada.", + "es": "Esta conversación no existe o no tienes permiso para acceder a ella. Si es tu conversación, intenta cambiar al espacio de trabajo donde fue creada.", + "ar": "هذه المحادثة غير موجودة أو ليس لديك إذن للوصول إليها. إذا كانت هذه محادثتك، حاول التبديل إلى مساحة العمل التي تم إنشاؤها فيها.", + "fr": "Cette conversation n'existe pas ou vous n'avez pas la permission d'y accéder. S'il s'agit de votre conversation, essayez de passer à l'espace de travail où elle a été créée.", + "tr": "Bu konuşma mevcut değil veya erişim izniniz yok. Bu sizin konuşmanızsa, oluşturulduğu çalışma alanına geçmeyi deneyin.", + "de": "Diese Konversation existiert nicht oder Sie haben keine Berechtigung darauf zuzugreifen. Wenn dies Ihre Konversation ist, versuchen Sie zum Arbeitsbereich zu wechseln, in dem sie erstellt wurde.", + "uk": "Ця розмова не існує або у вас немає дозволу на доступ до неї. Якщо це ваша розмова, спробуйте перейти до робочої області, де вона була створена." }, "CONVERSATION$FAILED_TO_START_WITH_ERROR": { "en": "Failed to start conversation: {{error}}", From 5d1f9f815ac35421bad541e4dc56cdab67979b92 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:50:42 +0700 Subject: [PATCH 56/92] fix(frontend): preserve settings page route on browser refresh (org project) (#13462) --- .../__tests__/utils/permission-checks.test.ts | 238 ++++++++++++++---- frontend/src/types/org.ts | 9 + frontend/src/utils/org/permission-checks.ts | 41 ++- 3 files changed, 241 insertions(+), 47 deletions(-) diff --git a/frontend/__tests__/utils/permission-checks.test.ts b/frontend/__tests__/utils/permission-checks.test.ts index d4730cb2a7..db5a09951e 100644 --- a/frontend/__tests__/utils/permission-checks.test.ts +++ b/frontend/__tests__/utils/permission-checks.test.ts @@ -1,10 +1,18 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { PermissionKey } from "#/utils/org/permissions"; +import { OrganizationMember, OrganizationsQueryData } from "#/types/org"; +import { + getAvailableRolesAUserCanAssign, + getActiveOrganizationUser, +} from "#/utils/org/permission-checks"; +import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store"; +import { queryClient } from "#/query-client-config"; -// Mock dependencies for getActiveOrganizationUser tests +// Mock dependencies vi.mock("#/api/organization-service/organization-service.api", () => ({ organizationService: { getMe: vi.fn(), + getOrganizations: vi.fn(), }, })); @@ -12,49 +20,60 @@ vi.mock("#/stores/selected-organization-store", () => ({ getSelectedOrganizationIdFromStore: vi.fn(), })); -vi.mock("#/utils/query-client-getters", () => ({ - getMeFromQueryClient: vi.fn(), -})); - vi.mock("#/query-client-config", () => ({ queryClient: { + getQueryData: vi.fn(), + fetchQuery: vi.fn(), setQueryData: vi.fn(), }, })); -// Import after mocks are set up -import { - getAvailableRolesAUserCanAssign, - getActiveOrganizationUser, -} from "#/utils/org/permission-checks"; -import { organizationService } from "#/api/organization-service/organization-service.api"; -import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store"; -import { getMeFromQueryClient } from "#/utils/query-client-getters"; +// Test fixtures +const mockUser: OrganizationMember = { + org_id: "org-1", + user_id: "user-1", + email: "test@example.com", + role: "admin", + llm_api_key: "", + max_iterations: 100, + llm_model: "gpt-4", + llm_api_key_for_byor: null, + llm_base_url: "", + status: "active", +}; + +const mockOrganizationsData: OrganizationsQueryData = { + items: [ + { id: "org-1", name: "Org 1" }, + { id: "org-2", name: "Org 2" }, + ] as OrganizationsQueryData["items"], + currentOrgId: "org-1", +}; describe("getAvailableRolesAUserCanAssign", () => { - it("returns empty array if user has no permissions", () => { - const result = getAvailableRolesAUserCanAssign([]); - expect(result).toEqual([]); - }); + it("returns empty array if user has no permissions", () => { + const result = getAvailableRolesAUserCanAssign([]); + expect(result).toEqual([]); + }); - it("returns only roles the user has permission for", () => { - const userPermissions: PermissionKey[] = [ - "change_user_role:member", - "change_user_role:admin", - ]; - const result = getAvailableRolesAUserCanAssign(userPermissions); - expect(result.sort()).toEqual(["admin", "member"].sort()); - }); + it("returns only roles the user has permission for", () => { + const userPermissions: PermissionKey[] = [ + "change_user_role:member", + "change_user_role:admin", + ]; + const result = getAvailableRolesAUserCanAssign(userPermissions); + expect(result.sort()).toEqual(["admin", "member"].sort()); + }); - it("returns all roles if user has all permissions", () => { - const allPermissions: PermissionKey[] = [ - "change_user_role:member", - "change_user_role:admin", - "change_user_role:owner", - ]; - const result = getAvailableRolesAUserCanAssign(allPermissions); - expect(result.sort()).toEqual(["member", "admin", "owner"].sort()); - }); + it("returns all roles if user has all permissions", () => { + const allPermissions: PermissionKey[] = [ + "change_user_role:member", + "change_user_role:admin", + "change_user_role:owner", + ]; + const result = getAvailableRolesAUserCanAssign(allPermissions); + expect(result.sort()).toEqual(["member", "admin", "owner"].sort()); + }); }); describe("getActiveOrganizationUser", () => { @@ -62,18 +81,147 @@ describe("getActiveOrganizationUser", () => { vi.clearAllMocks(); }); - it("should return undefined when API call throws an error", async () => { - // Arrange: orgId exists, cache is empty, API call fails - vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1"); - vi.mocked(getMeFromQueryClient).mockReturnValue(undefined); - vi.mocked(organizationService.getMe).mockRejectedValue( - new Error("Network error"), - ); + describe("when orgId exists in store", () => { + it("should fetch user directly using stored orgId", async () => { + // Arrange + vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1"); + vi.mocked(queryClient.fetchQuery).mockResolvedValue(mockUser); - // Act - const result = await getActiveOrganizationUser(); + // Act + const result = await getActiveOrganizationUser(); - // Assert: should return undefined instead of propagating the error - expect(result).toBeUndefined(); + // Assert + expect(result).toEqual(mockUser); + expect(queryClient.getQueryData).not.toHaveBeenCalled(); + expect(queryClient.fetchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ["organizations", "org-1", "me"], + }), + ); + }); + + it("should return undefined when user fetch fails", async () => { + // Arrange + vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1"); + vi.mocked(queryClient.fetchQuery).mockRejectedValue( + new Error("Network error"), + ); + + // Act + const result = await getActiveOrganizationUser(); + + // Assert + expect(result).toBeUndefined(); + }); + }); + + describe("when orgId is null in store (page refresh scenario)", () => { + beforeEach(() => { + vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue(null); + }); + + it("should use currentOrgId from cached organizations data", async () => { + // Arrange + vi.mocked(queryClient.getQueryData).mockReturnValue( + mockOrganizationsData, + ); + vi.mocked(queryClient.fetchQuery).mockResolvedValue(mockUser); + + // Act + const result = await getActiveOrganizationUser(); + + // Assert + expect(result).toEqual(mockUser); + expect(queryClient.getQueryData).toHaveBeenCalledWith(["organizations"]); + expect(queryClient.fetchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ["organizations", "org-1", "me"], + }), + ); + }); + + it("should fallback to first org when currentOrgId is null", async () => { + // Arrange + const dataWithoutCurrentOrg: OrganizationsQueryData = { + items: [ + { id: "first-org" }, + { id: "second-org" }, + ] as OrganizationsQueryData["items"], + currentOrgId: null, + }; + vi.mocked(queryClient.getQueryData).mockReturnValue( + dataWithoutCurrentOrg, + ); + vi.mocked(queryClient.fetchQuery).mockResolvedValue(mockUser); + + // Act + const result = await getActiveOrganizationUser(); + + // Assert + expect(result).toEqual(mockUser); + expect(queryClient.fetchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ["organizations", "first-org", "me"], + }), + ); + }); + + it("should fetch organizations when not in cache", async () => { + // Arrange + vi.mocked(queryClient.getQueryData).mockReturnValue(undefined); + vi.mocked(queryClient.fetchQuery) + .mockResolvedValueOnce(mockOrganizationsData) // First call: fetch organizations + .mockResolvedValueOnce(mockUser); // Second call: fetch user + + // Act + const result = await getActiveOrganizationUser(); + + // Assert + expect(result).toEqual(mockUser); + expect(queryClient.fetchQuery).toHaveBeenCalledTimes(2); + expect(queryClient.fetchQuery).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + queryKey: ["organizations"], + }), + ); + expect(queryClient.fetchQuery).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + queryKey: ["organizations", "org-1", "me"], + }), + ); + }); + + it("should return undefined when fetching organizations fails", async () => { + // Arrange + vi.mocked(queryClient.getQueryData).mockReturnValue(undefined); + vi.mocked(queryClient.fetchQuery).mockRejectedValue( + new Error("Failed to fetch organizations"), + ); + + // Act + const result = await getActiveOrganizationUser(); + + // Assert + expect(result).toBeUndefined(); + }); + + it("should return undefined when no organizations exist", async () => { + // Arrange + const emptyData: OrganizationsQueryData = { + items: [], + currentOrgId: null, + }; + vi.mocked(queryClient.getQueryData).mockReturnValue(emptyData); + + // Act + const result = await getActiveOrganizationUser(); + + // Assert + expect(result).toBeUndefined(); + // Should not attempt to fetch user since there's no orgId + expect(queryClient.fetchQuery).not.toHaveBeenCalled(); + }); }); }); diff --git a/frontend/src/types/org.ts b/frontend/src/types/org.ts index a527d49c47..a7b83c1092 100644 --- a/frontend/src/types/org.ts +++ b/frontend/src/types/org.ts @@ -56,3 +56,12 @@ export interface OrganizationMembersPage { export type UpdateOrganizationMemberParams = Partial< Omit >; + +/** + * Query data structure for the organizations query. + * This represents the raw data returned by queryClient before any `select` transform. + */ +export type OrganizationsQueryData = { + items: Organization[]; + currentOrgId: string | null; +}; diff --git a/frontend/src/utils/org/permission-checks.ts b/frontend/src/utils/org/permission-checks.ts index 5bf9db857e..4b439af6d8 100644 --- a/frontend/src/utils/org/permission-checks.ts +++ b/frontend/src/utils/org/permission-checks.ts @@ -1,6 +1,10 @@ import { organizationService } from "#/api/organization-service/organization-service.api"; import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store"; -import { OrganizationMember, OrganizationUserRole } from "#/types/org"; +import { + OrganizationMember, + OrganizationsQueryData, + OrganizationUserRole, +} from "#/types/org"; import { PermissionKey } from "./permissions"; import { queryClient } from "#/query-client-config"; @@ -8,12 +12,45 @@ import { queryClient } from "#/query-client-config"; * Get the active organization user. * Uses React Query's fetchQuery to leverage request deduplication, * preventing duplicate API calls when multiple consumers request the same data. + * + * On page refresh, the Zustand store resets and orgId becomes null. + * In this case, we retrieve the organization from the query cache or fetch it + * from the backend to ensure permission checks work correctly. + * * @returns OrganizationMember */ export const getActiveOrganizationUser = async (): Promise< OrganizationMember | undefined > => { - const orgId = getSelectedOrganizationIdFromStore(); + let orgId = getSelectedOrganizationIdFromStore(); + + // If no orgId in store (e.g., after page refresh), try to get it from query cache or fetch + if (!orgId) { + // Check if organizations data is already in the cache + let organizationsData = queryClient.getQueryData([ + "organizations", + ]); + + // If not in cache, fetch it + if (!organizationsData) { + try { + organizationsData = await queryClient.fetchQuery({ + queryKey: ["organizations"], + queryFn: organizationService.getOrganizations, + staleTime: 1000 * 60 * 5, // 5 minutes - matches useOrganizations hook + }); + } catch { + return undefined; + } + } + + // Use currentOrgId from backend (user's last selected org) or first org as fallback + orgId = + organizationsData?.currentOrgId ?? + organizationsData?.items?.[0]?.id ?? + null; + } + if (!orgId) return undefined; try { From 35a40ddee87427c1fa3f156be42965ef5b61a840 Mon Sep 17 00:00:00 2001 From: Chris Bagwell Date: Wed, 18 Mar 2026 10:55:48 -0500 Subject: [PATCH 57/92] fix: handle containers with tagless images in DockerSandboxService (#13238) --- .../sandbox/docker_sandbox_service.py | 6 ++ .../app_server/test_docker_sandbox_service.py | 55 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/openhands/app_server/sandbox/docker_sandbox_service.py b/openhands/app_server/sandbox/docker_sandbox_service.py index cb1466a8fa..6c692a680a 100644 --- a/openhands/app_server/sandbox/docker_sandbox_service.py +++ b/openhands/app_server/sandbox/docker_sandbox_service.py @@ -197,6 +197,12 @@ class DockerSandboxService(SandboxService): ) ) + if not container.image.tags: + _logger.debug( + f'Skipping container {container.name!r}: image has no tags (image id: {container.image.id})' + ) + return None + return SandboxInfo( id=container.name, created_by_user_id=None, diff --git a/tests/unit/app_server/test_docker_sandbox_service.py b/tests/unit/app_server/test_docker_sandbox_service.py index b951167d02..23a6d51b04 100644 --- a/tests/unit/app_server/test_docker_sandbox_service.py +++ b/tests/unit/app_server/test_docker_sandbox_service.py @@ -245,6 +245,61 @@ class TestDockerSandboxService: assert len(result.items) == 0 assert result.next_page_id is None + async def test_search_sandboxes_skips_containers_with_no_image_tags( + self, service, mock_running_container + ): + """Test that containers with tagless images are skipped without crashing. + + Regression test: when a container's image has been rebuilt with the same tag, + the old container's image loses its tags, causing container.image.tags to be + an empty list. Previously this caused an IndexError. + """ + # Setup a container with no image tags (e.g. image was retagged/rebuilt) + tagless_container = MagicMock() + tagless_container.name = 'oh-test-tagless' + tagless_container.status = 'paused' + tagless_container.image.tags = [] + tagless_container.image.id = 'sha256:abc123def456' + tagless_container.attrs = { + 'Created': '2024-01-15T10:30:00.000000000Z', + 'Config': {'Env': []}, + 'NetworkSettings': {'Ports': {}}, + } + + service.docker_client.containers.list.return_value = [ + mock_running_container, + tagless_container, + ] + service.httpx_client.get.return_value.raise_for_status.return_value = None + + # Execute - should not raise IndexError + result = await service.search_sandboxes() + + # Verify - only the properly tagged container is returned + assert isinstance(result, SandboxPage) + assert len(result.items) == 1 + assert result.items[0].id == 'oh-test-abc123' + + async def test_get_sandbox_returns_none_for_tagless_image(self, service): + """Test that get_sandbox returns None for containers with tagless images.""" + tagless_container = MagicMock() + tagless_container.name = 'oh-test-tagless' + tagless_container.status = 'paused' + tagless_container.image.tags = [] + tagless_container.image.id = 'sha256:abc123def456' + tagless_container.attrs = { + 'Created': '2024-01-15T10:30:00.000000000Z', + 'Config': {'Env': []}, + 'NetworkSettings': {'Ports': {}}, + } + service.docker_client.containers.get.return_value = tagless_container + + # Execute - should not raise IndexError + result = await service.get_sandbox('oh-test-tagless') + + # Verify - returns None for tagless container + assert result is None + async def test_search_sandboxes_filters_by_prefix(self, service): """Test that search filters containers by name prefix.""" # Setup From 991585c05d995e5a6d89308fbef2e9d8aea7de7c Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Wed, 18 Mar 2026 16:00:23 +0000 Subject: [PATCH 58/92] =?UTF-8?q?docs:=20add=20cross-repo=20testing=20skil?= =?UTF-8?q?l=20for=20SDK=20=E2=86=94=20OH=20Cloud=20e2e=20workflow=20(#134?= =?UTF-8?q?46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: openhands --- .agents/skills/cross-repo-testing/SKILL.md | 202 +++++++++++++++++++++ .github/workflows/pr-artifacts.yml | 136 ++++++++++++++ AGENTS.md | 34 ++++ 3 files changed, 372 insertions(+) create mode 100644 .agents/skills/cross-repo-testing/SKILL.md create mode 100644 .github/workflows/pr-artifacts.yml diff --git a/.agents/skills/cross-repo-testing/SKILL.md b/.agents/skills/cross-repo-testing/SKILL.md new file mode 100644 index 0000000000..3ca98ac58d --- /dev/null +++ b/.agents/skills/cross-repo-testing/SKILL.md @@ -0,0 +1,202 @@ +--- +name: cross-repo-testing +description: This skill should be used when the user asks to "test a cross-repo feature", "deploy a feature branch to staging", "test SDK against OH Cloud", "e2e test a cloud workspace feature", "test provider tokens", "test secrets inheritance", or when changes span the SDK and OpenHands server repos and need end-to-end validation against a staging deployment. +triggers: +- cross-repo +- staging deployment +- feature branch deploy +- test against cloud +- e2e cloud +--- + +# Cross-Repo Testing: SDK ↔ OpenHands Cloud + +How to end-to-end test features that span `OpenHands/software-agent-sdk` and `OpenHands/OpenHands` (the Cloud backend). + +## Repository Map + +| Repo | Role | What lives here | +|------|------|-----------------| +| [`software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) | Agent core | `openhands-sdk`, `openhands-workspace`, `openhands-tools` packages. `OpenHandsCloudWorkspace` lives here. | +| [`OpenHands`](https://github.com/OpenHands/OpenHands) | Cloud backend | FastAPI server (`openhands/app_server/`), sandbox management, auth, enterprise integrations. Deployed as OH Cloud. | +| [`deploy`](https://github.com/OpenHands/deploy) | Infrastructure | Helm charts + GitHub Actions that build the enterprise Docker image and deploy to staging/production. | + +**Data flow:** SDK client → OH Cloud API (`/api/v1/...`) → sandbox agent-server (inside runtime container) + +## When You Need This + +There are **two flows** depending on which direction the dependency goes: + +| Flow | When | Example | +|------|------|---------| +| **A — SDK client → new Cloud API** | The SDK calls an API that doesn't exist yet on production | `workspace.get_llm()` calling `GET /api/v1/users/me?expose_secrets=true` | +| **B — OH server → new SDK code** | The Cloud server needs unreleased SDK packages or a new agent-server image | Server consumes a new tool, agent behavior, or workspace method from the SDK | + +Flow A only requires deploying the server PR. Flow B requires pinning the SDK to an unreleased commit in the server PR **and** using the SDK PR's agent-server image. Both flows may apply simultaneously. + +--- + +## Flow A: SDK Client Tests Against New Cloud API + +Use this when the SDK calls an endpoint that only exists on the server PR branch. + +### A1. Write and test the server-side changes + +In the `OpenHands` repo, implement the new API endpoint(s). Run unit tests: + +```bash +cd OpenHands +poetry run pytest tests/unit/app_server/test_.py -v +``` + +Push a PR. Wait for the **"Push Enterprise Image" (Docker) CI job** to succeed — this builds `ghcr.io/openhands/enterprise-server:sha-`. + +### A2. Write the SDK-side changes + +In `software-agent-sdk`, implement the client code (e.g., new methods on `OpenHandsCloudWorkspace`). Run SDK unit tests: + +```bash +cd software-agent-sdk +pip install -e openhands-sdk -e openhands-workspace +pytest tests/ -v +``` + +Push a PR. SDK CI is independent — it doesn't need the server changes to pass unit tests. + +### A3. Deploy the server PR to staging + +See [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) below. + +### A4. Run the SDK e2e test against staging + +See [Running E2E Tests Against Staging](#running-e2e-tests-against-staging) below. + +--- + +## Flow B: OH Server Needs Unreleased SDK Code + +Use this when the Cloud server depends on SDK changes that haven't been released to PyPI yet. The server's runtime containers run the `agent-server` image built from the SDK repo, so the server PR must be configured to use the SDK PR's image and packages. + +### B1. Get the SDK PR merged (or identify the commit) + +The SDK PR must have CI pass so its agent-server Docker image is built. The image is tagged with the **merge-commit SHA** from GitHub Actions — NOT the head-commit SHA shown in the PR. + +Find the correct image tag: +- Check the SDK PR description for an `AGENT_SERVER_IMAGES` section +- Or check the "Consolidate Build Information" CI job for `"short_sha": ""` + +### B2. Pin SDK packages to the commit in the OpenHands PR + +In the `OpenHands` repo PR, pin all 3 SDK packages (`openhands-sdk`, `openhands-agent-server`, `openhands-tools`) to the unreleased commit and update the agent-server image tag. This involves editing 3 files and regenerating 3 lock files. + +Follow the **`update-sdk` skill** → "Development: Pin SDK to an Unreleased Commit" section for the full procedure and file-by-file instructions. + +### B3. Wait for the OpenHands enterprise image to build + +Push the pinned changes. The OpenHands CI will build a new enterprise Docker image (`ghcr.io/openhands/enterprise-server:sha-`) that bundles the unreleased SDK. Wait for the "Push Enterprise Image" job to succeed. + +### B4. Deploy and test + +Follow [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) using the new OpenHands commit SHA. + +### B5. Before merging: remove the pin + +**CI guard:** `check-package-versions.yml` blocks merge to `main` if `[tool.poetry.dependencies]` contains `rev` fields. Before the OpenHands PR can merge, the SDK PR must be merged and released to PyPI, then the pin must be replaced with the released version number. + +--- + +## Deploying to a Staging Feature Environment + +The `deploy` repo creates preview environments from OpenHands PRs. + +**Option A — GitHub Actions UI (preferred):** +Go to `OpenHands/deploy` → Actions → "Create OpenHands preview PR" → enter the OpenHands PR number. This creates a branch `ohpr--` and opens a deploy PR. + +**Option B — Update an existing feature branch:** +```bash +cd deploy +git checkout ohpr-- +# In .github/workflows/deploy.yaml, update BOTH: +# OPENHANDS_SHA: "" +# OPENHANDS_RUNTIME_IMAGE_TAG: "-nikolaik" +git commit -am "Update OPENHANDS_SHA to " && git push +``` + +**Before updating the SHA**, verify the enterprise Docker image exists: +```bash +gh api repos/OpenHands/OpenHands/actions/runs \ + --jq '.workflow_runs[] | select(.head_sha=="") | "\(.name): \(.conclusion)"' \ + | grep Docker +# Must show: "Docker: success" +``` + +The deploy CI auto-triggers and creates the environment at: +``` +https://ohpr--.staging.all-hands.dev +``` + +**Wait for it to be live:** +```bash +curl -s -o /dev/null -w "%{http_code}" https://ohpr--.staging.all-hands.dev/api/v1/health +# 401 = server is up (auth required). DNS may take 1-2 min on first deploy. +``` + +## Running E2E Tests Against Staging + +**Critical: Feature deployments have their own Keycloak instance.** API keys from `app.all-hands.dev` or `$OPENHANDS_API_KEY` will NOT work. You need a test API key issued by the specific feature deployment's Keycloak. + +**You (the agent) cannot obtain this key yourself** — the feature environment requires interactive browser login with credentials you do not have. You must **ask the user** to: +1. Log in to the feature deployment at `https://ohpr--.staging.all-hands.dev` in their browser +2. Generate a test API key from the UI +3. Provide the key to you so you can proceed with e2e testing + +Do **not** attempt to log in via the browser or guess credentials. Wait for the user to supply the key before running any e2e tests. + +```python +from openhands.workspace import OpenHandsCloudWorkspace + +STAGING = "https://ohpr--.staging.all-hands.dev" + +with OpenHandsCloudWorkspace( + cloud_api_url=STAGING, + cloud_api_key="", +) as workspace: + # Test the new feature + llm = workspace.get_llm() + secrets = workspace.get_secrets() + print(f"LLM: {llm.model}, secrets: {list(secrets.keys())}") +``` + +Or run an example script: +```bash +OPENHANDS_CLOUD_API_KEY="" \ +OPENHANDS_CLOUD_API_URL="https://ohpr--.staging.all-hands.dev" \ +python examples/02_remote_agent_server/10_cloud_workspace_saas_credentials.py +``` + +### Recording results + +Both repos support a `.pr/` directory for temporary PR artifacts (design docs, test logs, scripts). These files are automatically removed when the PR is approved — see `.github/workflows/pr-artifacts.yml` and the "PR-Specific Artifacts" section in each repo's `AGENTS.md`. + +Push test output to the `.pr/logs/` directory of whichever repo you're working in: +```bash +mkdir -p .pr/logs +python test_script.py 2>&1 | tee .pr/logs/.log +git add -f .pr/logs/ +git commit -m "docs: add e2e test results" && git push +``` + +Comment on **both PRs** with pass/fail summary and link to logs. + +## Key Gotchas + +| Gotcha | Details | +|--------|---------| +| **Feature env auth is isolated** | Each `ohpr-*` deployment has its own Keycloak. Production API keys don't work. Agents cannot log in — you must ask the user to provide a test API key from the feature deployment's UI. | +| **Two SHAs in deploy.yaml** | `OPENHANDS_SHA` and `OPENHANDS_RUNTIME_IMAGE_TAG` must both be updated. The runtime tag is `-nikolaik`. | +| **Enterprise image must exist** | The Docker CI job on the OpenHands PR must succeed before you can deploy. If it hasn't run, push an empty commit to trigger it. | +| **DNS propagation** | First deployment of a new branch takes 1-2 min for DNS. Subsequent deploys are instant. | +| **Merge-commit SHA ≠ head SHA** | SDK CI tags Docker images with GitHub Actions' merge-commit SHA, not the PR head SHA. Check the SDK PR description or CI logs for the correct tag. | +| **SDK pin blocks merge** | `check-package-versions.yml` prevents merging an OpenHands PR that has `rev` fields in `[tool.poetry.dependencies]`. The SDK must be released to PyPI first. | +| **Flow A: stock agent-server is fine** | When only the Cloud API changes, `OpenHandsCloudWorkspace` talks to the Cloud server, not the agent-server. No custom image needed. | +| **Flow B: agent-server image is required** | When the server needs new SDK code inside runtime containers, you must pin to the SDK PR's agent-server image. | diff --git a/.github/workflows/pr-artifacts.yml b/.github/workflows/pr-artifacts.yml new file mode 100644 index 0000000000..ef656c1119 --- /dev/null +++ b/.github/workflows/pr-artifacts.yml @@ -0,0 +1,136 @@ +--- +name: PR Artifacts + +on: + workflow_dispatch: # Manual trigger for testing + pull_request: + types: [opened, synchronize, reopened] + branches: [main] + pull_request_review: + types: [submitted] + +jobs: + # Auto-remove .pr/ directory when a reviewer approves + cleanup-on-approval: + concurrency: + group: cleanup-pr-artifacts-${{ github.event.pull_request.number }} + cancel-in-progress: false + if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Check if fork PR + id: check-fork + run: | + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then + echo "is_fork=true" >> $GITHUB_OUTPUT + echo "::notice::Fork PR detected - skipping auto-cleanup (manual removal required)" + else + echo "is_fork=false" >> $GITHUB_OUTPUT + fi + + - uses: actions/checkout@v5 + if: steps.check-fork.outputs.is_fork == 'false' + with: + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }} + + - name: Remove .pr/ directory + id: remove + if: steps.check-fork.outputs.is_fork == 'false' + run: | + if [ -d ".pr" ]; then + git config user.name "allhands-bot" + git config user.email "allhands-bot@users.noreply.github.com" + git rm -rf .pr/ + git commit -m "chore: Remove PR-only artifacts [automated]" + git push || { + echo "::error::Failed to push cleanup commit. Check branch protection rules." + exit 1 + } + echo "removed=true" >> $GITHUB_OUTPUT + echo "::notice::Removed .pr/ directory" + else + echo "removed=false" >> $GITHUB_OUTPUT + echo "::notice::No .pr/ directory to remove" + fi + + - name: Update PR comment after cleanup + if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const body = `${marker} + ✅ **PR Artifacts Cleaned Up** + + The \`.pr/\` directory has been automatically removed. + `; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: body, + }); + } + + # Warn if .pr/ directory exists (will be auto-removed on approval) + check-pr-artifacts: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v5 + + - name: Check for .pr/ directory + id: check + run: | + if [ -d ".pr" ]; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "::warning::.pr/ directory exists and will be automatically removed when the PR is approved. For fork PRs, manual removal is required before merging." + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Post or update PR comment + if: steps.check.outputs.exists == 'true' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const body = `${marker} + 📁 **PR Artifacts Notice** + + This PR contains a \`.pr/\` directory with PR-specific documents. This directory will be **automatically removed** when the PR is approved. + + > For fork PRs: Manual removal is required before merging. + `; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body.includes(marker)); + if (!existing) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } diff --git a/AGENTS.md b/AGENTS.md index 7a0bbc044a..811f3bdcf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,40 @@ then re-run the command to ensure it passes. Common issues include: - Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files - When remote has new changes, use `git fetch upstream && git rebase upstream/` on the same branch +## PR-Specific Artifacts (`.pr/` directory) + +When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root. + +### Usage + +``` +.pr/ +├── design.md # Design decisions and architecture notes +├── analysis.md # Investigation or debugging notes +├── logs/ # Test output or CI logs for reviewer reference +└── notes.md # Any other PR-specific content +``` + +### How It Works + +1. **Notification**: When `.pr/` exists, a comment is posted to the PR conversation alerting reviewers +2. **Auto-cleanup**: When the PR is approved, the `.pr/` directory is automatically removed via `.github/workflows/pr-artifacts.yml` +3. **Fork PRs**: Auto-cleanup cannot push to forks, so manual removal is required before merging + +### Important Notes + +- Do NOT put anything in `.pr/` that needs to be preserved after merge +- The `.pr/` check passes (green ✅) during development — it only posts a notification, not a blocking error +- For fork PRs: You must manually remove `.pr/` before the PR can be merged + +### When to Use + +- Complex refactoring that benefits from written design rationale +- Debugging sessions where you want to document your investigation +- E2E test results or logs that demonstrate a cross-repo feature works +- Feature implementations that need temporary planning docs +- Any analysis that helps reviewers understand the PR but isn't needed long-term + ## Repository Structure Backend: - Located in the `openhands` directory From fb23418803e463a356778a172e2e8eadce70f1dd Mon Sep 17 00:00:00 2001 From: aivong-openhands Date: Wed, 18 Mar 2026 11:03:56 -0500 Subject: [PATCH 59/92] clarify docstring for provider token reference (#13386) Co-authored-by: openhands --- openhands/integrations/provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index aa210eae18..ddec99d033 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -471,7 +471,7 @@ class ProviderHandler: def check_cmd_action_for_provider_token_ref( cls, event: Action ) -> list[ProviderType]: - """Detect if agent run action is using a provider token (e.g $GITHUB_TOKEN) + """Detect if agent run action is using a provider token (e.g github_token) Returns a list of providers which are called by the agent """ if not isinstance(event, CmdRunAction): From fb7333aa62aee39e86bd4c3a659a4f3be2e5074e Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Wed, 18 Mar 2026 17:10:07 +0100 Subject: [PATCH 60/92] fix: stop calling agent-server /generate_title (#13093) Co-authored-by: openhands --- .../set_title_callback_processor.py | 48 ++- .../test_set_title_callback_processor.py | 294 ++++++++++++++++++ 2 files changed, 332 insertions(+), 10 deletions(-) create mode 100644 tests/unit/app_server/test_set_title_callback_processor.py diff --git a/openhands/app_server/event_callback/set_title_callback_processor.py b/openhands/app_server/event_callback/set_title_callback_processor.py index 071d28b5f9..37d70370b0 100644 --- a/openhands/app_server/event_callback/set_title_callback_processor.py +++ b/openhands/app_server/event_callback/set_title_callback_processor.py @@ -1,6 +1,9 @@ +import asyncio import logging from uuid import UUID +import httpx + from openhands.app_server.app_conversation.app_conversation_models import ( AppConversationInfo, ) @@ -22,6 +25,9 @@ from openhands.sdk import Event, MessageEvent _logger = logging.getLogger(__name__) +# Poll with ~3.75s total wait per message event before retrying later. +_TITLE_POLL_DELAYS_S = (0.25, 0.5, 1.0, 2.0) + class SetTitleCallbackProcessor(EventCallbackProcessor): """Callback processor which sets conversation titles.""" @@ -51,7 +57,6 @@ class SetTitleCallbackProcessor(EventCallbackProcessor): get_app_conversation_info_service(state) as app_conversation_info_service, get_httpx_client(state) as httpx_client, ): - # Generate a title for the conversation app_conversation = await app_conversation_service.get_app_conversation( conversation_id ) @@ -61,15 +66,38 @@ class SetTitleCallbackProcessor(EventCallbackProcessor): app_conversation_url = replace_localhost_hostname_for_docker( app_conversation_url ) - response = await httpx_client.post( - f'{app_conversation_url}/generate_title', - headers={ - 'X-Session-API-Key': app_conversation.session_api_key, - }, - content='{}', - ) - response.raise_for_status() - title = response.json()['title'] + + title = None + for delay_s in _TITLE_POLL_DELAYS_S: + try: + response = await httpx_client.get( + app_conversation_url, + headers={ + 'X-Session-API-Key': app_conversation.session_api_key, + }, + ) + response.raise_for_status() + except httpx.HTTPError as exc: + # Transient agent-server failures are acceptable; retry later. + _logger.debug( + 'Title poll failed for conversation %s: %s', + conversation_id, + exc, + ) + else: + title = response.json().get('title') + if title: + break + # Backoff applies to both missing-title responses and transient errors. + await asyncio.sleep(delay_s) + + if not title: + # Keep the callback active so later message events can retry. + _logger.info( + f'Conversation {conversation_id} title not available yet; ' + 'will retry on a future message event.' + ) + return None # Save the conversation info info = AppConversationInfo( diff --git a/tests/unit/app_server/test_set_title_callback_processor.py b/tests/unit/app_server/test_set_title_callback_processor.py new file mode 100644 index 0000000000..2c992be3f9 --- /dev/null +++ b/tests/unit/app_server/test_set_title_callback_processor.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import httpx +import pytest + +from openhands.app_server.app_conversation.app_conversation_models import ( + AppConversation, +) +from openhands.app_server.event_callback.event_callback_models import ( + EventCallback, + EventCallbackStatus, +) +from openhands.app_server.event_callback.set_title_callback_processor import ( + SetTitleCallbackProcessor, +) +from openhands.app_server.utils.docker_utils import ( + replace_localhost_hostname_for_docker, +) +from openhands.sdk import Message, MessageEvent, TextContent + + +class _FakeHttpxClient: + def __init__(self, titles: list[str | None]): + self._titles = titles + self.calls: list[tuple[str, dict[str, str] | None]] = [] + + async def get(self, url: str, headers: dict[str, str] | None = None): + self.calls.append((url, headers)) + idx = min(len(self.calls) - 1, len(self._titles) - 1) + request = httpx.Request('GET', url) + return httpx.Response(200, json={'title': self._titles[idx]}, request=request) + + +class _FailingHttpxClient: + def __init__(self, error: httpx.HTTPError): + self._error = error + self.calls: list[tuple[str, dict[str, str] | None]] = [] + + async def get(self, url: str, headers: dict[str, str] | None = None): + self.calls.append((url, headers)) + raise self._error + + +@asynccontextmanager +async def _ctx(obj): + yield obj + + +@pytest.mark.asyncio +async def test_set_title_callback_processor_fetches_title_from_conversation(): + conversation_id = uuid4() + session_api_key = 'test-session-key' + conversation_url = f'http://localhost:8000/api/conversations/{conversation_id.hex}' + + app_conversation = AppConversation( + id=conversation_id, + created_by_user_id='user', + sandbox_id='sandbox', + title=f'Conversation {conversation_id.hex[:5]}', + conversation_url=conversation_url, + session_api_key=session_api_key, + ) + + app_conversation_service = AsyncMock() + app_conversation_service.get_app_conversation.return_value = app_conversation + + app_conversation_info_service = AsyncMock() + event_callback_service = AsyncMock() + + httpx_client = _FakeHttpxClient(titles=[None, None, None, 'Generated Title']) + + def get_app_conversation_service(_state): + return _ctx(app_conversation_service) + + def get_app_conversation_info_service(_state): + return _ctx(app_conversation_info_service) + + def get_event_callback_service(_state): + return _ctx(event_callback_service) + + def get_httpx_client(_state): + return _ctx(httpx_client) + + callback = EventCallback( + conversation_id=conversation_id, processor=SetTitleCallbackProcessor() + ) + event = MessageEvent( + source='user', + llm_message=Message(role='user', content=[TextContent(text='hi')]), + ) + + processor = SetTitleCallbackProcessor() + + with ( + patch( + 'openhands.app_server.config.get_app_conversation_service', + get_app_conversation_service, + ), + patch( + 'openhands.app_server.config.get_app_conversation_info_service', + get_app_conversation_info_service, + ), + patch( + 'openhands.app_server.config.get_event_callback_service', + get_event_callback_service, + ), + patch('openhands.app_server.config.get_httpx_client', get_httpx_client), + patch( + 'openhands.app_server.event_callback.' + 'set_title_callback_processor.asyncio.sleep', + new=AsyncMock(), + ), + ): + result = await processor(conversation_id, callback, event) + + assert result is not None + + assert len(httpx_client.calls) == 4 + expected_url = replace_localhost_hostname_for_docker(conversation_url) + assert httpx_client.calls[0][0] == expected_url + assert httpx_client.calls[0][1] == {'X-Session-API-Key': session_api_key} + + app_conversation_info_service.save_app_conversation_info.assert_called_once() + saved_info = app_conversation_info_service.save_app_conversation_info.call_args[0][ + 0 + ] + assert saved_info.title == 'Generated Title' + + assert callback.status == EventCallbackStatus.DISABLED + event_callback_service.save_event_callback.assert_called_once() + + +@pytest.mark.asyncio +async def test_set_title_callback_processor_no_title_yet_returns_none(): + conversation_id = uuid4() + session_api_key = 'test-session-key' + conversation_url = f'http://localhost:8000/api/conversations/{conversation_id.hex}' + + app_conversation = AppConversation( + id=conversation_id, + created_by_user_id='user', + sandbox_id='sandbox', + title=f'Conversation {conversation_id.hex[:5]}', + conversation_url=conversation_url, + session_api_key=session_api_key, + ) + + app_conversation_service = AsyncMock() + app_conversation_service.get_app_conversation.return_value = app_conversation + + app_conversation_info_service = AsyncMock() + event_callback_service = AsyncMock() + + httpx_client = _FakeHttpxClient(titles=[None]) + + def get_app_conversation_service(_state): + return _ctx(app_conversation_service) + + def get_app_conversation_info_service(_state): + return _ctx(app_conversation_info_service) + + def get_event_callback_service(_state): + return _ctx(event_callback_service) + + def get_httpx_client(_state): + return _ctx(httpx_client) + + callback = EventCallback( + conversation_id=conversation_id, processor=SetTitleCallbackProcessor() + ) + event = MessageEvent( + source='user', + llm_message=Message(role='user', content=[TextContent(text='hi')]), + ) + + processor = SetTitleCallbackProcessor() + + with ( + patch( + 'openhands.app_server.config.get_app_conversation_service', + get_app_conversation_service, + ), + patch( + 'openhands.app_server.config.get_app_conversation_info_service', + get_app_conversation_info_service, + ), + patch( + 'openhands.app_server.config.get_event_callback_service', + get_event_callback_service, + ), + patch('openhands.app_server.config.get_httpx_client', get_httpx_client), + patch( + 'openhands.app_server.event_callback.' + 'set_title_callback_processor.asyncio.sleep', + new=AsyncMock(), + ), + ): + result = await processor(conversation_id, callback, event) + + assert result is None + + app_conversation_info_service.save_app_conversation_info.assert_not_called() + event_callback_service.save_event_callback.assert_not_called() + assert callback.status == EventCallbackStatus.ACTIVE + + +@pytest.mark.asyncio +async def test_set_title_callback_processor_request_errors_return_none(): + conversation_id = uuid4() + session_api_key = 'test-session-key' + conversation_url = f'http://localhost:8000/api/conversations/{conversation_id.hex}' + + app_conversation = AppConversation( + id=conversation_id, + created_by_user_id='user', + sandbox_id='sandbox', + title=f'Conversation {conversation_id.hex[:5]}', + conversation_url=conversation_url, + session_api_key=session_api_key, + ) + + app_conversation_service = AsyncMock() + app_conversation_service.get_app_conversation.return_value = app_conversation + + app_conversation_info_service = AsyncMock() + event_callback_service = AsyncMock() + + httpx_client = _FailingHttpxClient( + httpx.RequestError( + 'boom', + request=httpx.Request( + 'GET', replace_localhost_hostname_for_docker(conversation_url) + ), + ) + ) + + def get_app_conversation_service(_state): + return _ctx(app_conversation_service) + + def get_app_conversation_info_service(_state): + return _ctx(app_conversation_info_service) + + def get_event_callback_service(_state): + return _ctx(event_callback_service) + + def get_httpx_client(_state): + return _ctx(httpx_client) + + callback = EventCallback( + conversation_id=conversation_id, processor=SetTitleCallbackProcessor() + ) + event = MessageEvent( + source='user', + llm_message=Message(role='user', content=[TextContent(text='hi')]), + ) + + processor = SetTitleCallbackProcessor() + + with ( + patch( + 'openhands.app_server.config.get_app_conversation_service', + get_app_conversation_service, + ), + patch( + 'openhands.app_server.config.get_app_conversation_info_service', + get_app_conversation_info_service, + ), + patch( + 'openhands.app_server.config.get_event_callback_service', + get_event_callback_service, + ), + patch('openhands.app_server.config.get_httpx_client', get_httpx_client), + patch( + 'openhands.app_server.event_callback.' + 'set_title_callback_processor.asyncio.sleep', + new=AsyncMock(), + ), + patch( + 'openhands.app_server.event_callback.' + 'set_title_callback_processor._logger.debug' + ) as logger_debug, + ): + result = await processor(conversation_id, callback, event) + + assert result is None + assert len(httpx_client.calls) == 4 + assert logger_debug.call_count == 4 + app_conversation_info_service.save_app_conversation_info.assert_not_called() + event_callback_service.save_event_callback.assert_not_called() + assert callback.status == EventCallbackStatus.ACTIVE From eb9a822d4c5ad3283d12cc6c1dc5c72158862004 Mon Sep 17 00:00:00 2001 From: Jamie Chicago <87397251+jamiechicago312@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:10:22 -0500 Subject: [PATCH 61/92] Update CONTRIBUTING.md (#13463) --- CONTRIBUTING.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d30510981..13e601937b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,17 @@ For example, a PR title could be: - If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix), please include a short message that we can add to our changelog +## Becoming a Maintainer + +For contributors who have made significant and sustained contributions to the project, there is a possibility of joining the maintainer team. +The process for this is as follows: + +1. Any contributor who has made sustained and high-quality contributions to the codebase can be nominated by any maintainer. If you feel that you may qualify you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated. +2. Once a maintainer nominates a new maintainer, there will be a discussion period among the maintainers for at least 3 days. +3. If no concerns are raised the nomination will be accepted by acclamation, and if concerns are raised there will be a discussion and possible vote. + +Note that just making many PRs does not immediately imply that you will become a maintainer. We will be looking at sustained high-quality contributions over a period of time, as well as good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md). + ## Need Help? - **Slack**: [Join our community](https://openhands.dev/joinslack) From c62b47dcb13daa567860ae624a1fb32787b2dd37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E5=81=9A=E4=BA=86=E7=9D=A1=E5=A4=A7=E8=A7=89?= <64798754+stakeswky@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:36:52 +0800 Subject: [PATCH 62/92] fix: handle empty body in GitHub issue resolver (#13039) Co-authored-by: User --- openhands/resolver/interfaces/issue.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openhands/resolver/interfaces/issue.py b/openhands/resolver/interfaces/issue.py index d57ce22412..a456be5c70 100644 --- a/openhands/resolver/interfaces/issue.py +++ b/openhands/resolver/interfaces/issue.py @@ -8,7 +8,7 @@ from abc import ABC, abstractmethod from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, field_validator class ReviewThread(BaseModel): @@ -21,7 +21,13 @@ class Issue(BaseModel): repo: str number: int title: str - body: str + body: str = '' + + @field_validator('body', mode='before') + @classmethod + def body_must_not_be_none(cls, v: str | None) -> str: + return v if v is not None else '' + thread_comments: list[str] | None = None # Added field for issue thread comments closing_issues: list[str] | None = None review_comments: list[str] | None = None From 48cd85e47e7f87c5cc64ceafa28b4c412333e96f Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Wed, 18 Mar 2026 12:04:36 -0500 Subject: [PATCH 63/92] fix(security): add sleep to container wait loop (#12869) Co-authored-by: Claude Opus 4.6 --- openhands/security/invariant/analyzer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openhands/security/invariant/analyzer.py b/openhands/security/invariant/analyzer.py index bba78e4ace..a9548857c6 100644 --- a/openhands/security/invariant/analyzer.py +++ b/openhands/security/invariant/analyzer.py @@ -6,6 +6,7 @@ # Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above. # Tag: Legacy-V0 import re +import time import uuid from typing import Any @@ -71,15 +72,16 @@ class InvariantAnalyzer(SecurityAnalyzer): else: self.container = running_containers[0] - elapsed = 0 + start_time = time.time() while self.container.status != 'running': self.container = self.docker_client.containers.get(self.container_name) - elapsed += 1 + elapsed = time.time() - start_time logger.debug( - f'waiting for container to start: {elapsed}, container status: {self.container.status}' + f'waiting for container to start: {elapsed:.1f}s, container status: {self.container.status}' ) if elapsed > self.timeout: break + time.sleep(0.5) self.api_port = int( self.container.attrs['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort'] From 8e0386c4163e9e2d16a68a974dbd2150a6b1af12 Mon Sep 17 00:00:00 2001 From: Jordi Mas Date: Wed, 18 Mar 2026 18:17:43 +0100 Subject: [PATCH 64/92] feat: add Catalan translation (#13299) --- frontend/src/i18n/index.ts | 1 + frontend/src/i18n/translation.json | 3676 +++++++++++++++++++--------- 2 files changed, 2501 insertions(+), 1176 deletions(-) diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index f7a57b52a5..3234f41c11 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -16,6 +16,7 @@ export const AvailableLanguages = [ { label: "Italiano", value: "it" }, { label: "Português", value: "pt" }, { label: "Español", value: "es" }, + { label: "Català", value: "ca" }, { label: "Türkçe", value: "tr" }, { label: "Українська", value: "uk" }, ]; diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 93ec856439..f43c33b0d2 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -13,7 +13,8 @@ "fr": "La maintenance programmée commencera à {{time}}", "tr": "Planlı bakım {{time}} tarihinde başlayacak", "de": "Die geplante Wartung beginnt um {{time}}", - "uk": "Планове технічне обслуговування розпочнеться о {{time}}" + "uk": "Планове технічне обслуговування розпочнеться о {{time}}", + "ca": "El manteniment programat començarà a les {{time}}" }, "ALERT$FAULTY_MODELS_MESSAGE": { "en": "The following models are currently reporting errors:", @@ -29,7 +30,8 @@ "fr": "Les modèles suivants signalent actuellement des erreurs :", "tr": "Aşağıdaki modeller şu anda hata bildiriyor:", "de": "Die folgenden Modelle melden derzeit Fehler:", - "uk": "Наступні моделі наразі повідомляють про помилки:" + "uk": "Наступні моделі наразі повідомляють про помилки:", + "ca": "Els models següents estan informant d'errors actualment:" }, "AZURE_DEVOPS$CONNECT_ACCOUNT": { "en": "Connect Azure DevOps Account", @@ -45,7 +47,8 @@ "fr": "Connecter le compte Azure DevOps", "tr": "Azure DevOps hesabını bağla", "de": "Azure DevOps-Konto verbinden", - "uk": "Підключити обліковий запис Azure DevOps" + "uk": "Підключити обліковий запис Azure DevOps", + "ca": "Connecta el compte d'Azure DevOps" }, "GIT$AZURE_DEVOPS_TOKEN": { "en": "Azure DevOps Personal Access Token", @@ -61,7 +64,8 @@ "fr": "Jeton d'accès personnel Azure DevOps", "tr": "Azure DevOps kişisel erişim belirteci", "de": "Azure DevOps persönliches Zugriffstoken", - "uk": "Персональний токен доступу Azure DevOps" + "uk": "Персональний токен доступу Azure DevOps", + "ca": "Token d'accés personal d'Azure DevOps" }, "GIT$AZURE_DEVOPS_HOST": { "en": "Azure DevOps Organization", @@ -77,7 +81,8 @@ "fr": "Organisation Azure DevOps", "tr": "Azure DevOps kuruluş", "de": "Azure DevOps Organisation", - "uk": "Організація Azure DevOps" + "uk": "Організація Azure DevOps", + "ca": "Organització d'Azure DevOps" }, "GIT$AZURE_DEVOPS_HOST_PLACEHOLDER": { "en": "organization", @@ -93,7 +98,8 @@ "fr": "organisation", "tr": "kuruluş/proje", "de": "organisation/projekt", - "uk": "організація/проект" + "uk": "організація/проект", + "ca": "organització" }, "GIT$AZURE_DEVOPS_TOKEN_HELP": { "en": "How to create an Azure DevOps token", @@ -109,7 +115,8 @@ "fr": "Comment créer un jeton Azure DevOps", "tr": "Azure DevOps belirteci nasıl oluşturulur", "de": "Wie man ein Azure DevOps-Token erstellt", - "uk": "Як створити токен Azure DevOps" + "uk": "Як створити токен Azure DevOps", + "ca": "Com crear un token d'Azure DevOps" }, "MICROAGENT$NO_REPOSITORY_FOUND": { "en": "No repository found to launch microagent", @@ -125,7 +132,8 @@ "fr": "Aucun dépôt trouvé pour lancer le micro-agent", "tr": "Mikro ajanı başlatmak için depo bulunamadı", "de": "Kein Repository gefunden, um Microagent zu starten", - "uk": "Не знайдено репозиторій для запуску мікроагента" + "uk": "Не знайдено репозиторій для запуску мікроагента", + "ca": "No s'ha trobat cap repositori per llançar el microagent" }, "MICROAGENT$ADD_TO_MICROAGENT": { "en": "Add to Microagent", @@ -141,7 +149,8 @@ "fr": "Ajouter au micro-agent", "tr": "Mikro ajana ekle", "de": "Zum Microagent hinzufügen", - "uk": "Додати до мікроагента" + "uk": "Додати до мікроагента", + "ca": "Afegeix al Microagent" }, "MICROAGENT$WHAT_TO_ADD": { "en": "What would you like to add to the Microagent?", @@ -157,7 +166,8 @@ "fr": "Que souhaitez-vous ajouter au micro-agent ?", "tr": "Mikro ajana ne eklemek istersiniz?", "de": "Was möchten Sie zum Microagent hinzufügen?", - "uk": "Що ви хочете додати до мікроагента?" + "uk": "Що ви хочете додати до мікроагента?", + "ca": "Què vols afegir al Microagent?" }, "MICROAGENT$WHERE_TO_PUT": { "en": "Where should we put it?", @@ -173,7 +183,8 @@ "fr": "Où devons-nous le mettre ?", "tr": "Nereye koyalım?", "de": "Wo sollen wir es platzieren?", - "uk": "Куди ми повинні його помістити?" + "uk": "Куди ми повинні його помістити?", + "ca": "On ho hem de posar?" }, "MICROAGENT$ADD_TRIGGER": { "en": "Add a trigger for the microagent", @@ -189,7 +200,8 @@ "fr": "Ajouter un déclencheur pour le micro-agent", "tr": "Mikro ajan için bir tetikleyici ekleyin", "de": "Fügen Sie einen Auslöser für den Microagent hinzu", - "uk": "Додати тригер для мікроагента" + "uk": "Додати тригер для мікроагента", + "ca": "Afegeix un disparador per al microagent" }, "MICROAGENT$WHAT_TO_REMEMBER": { "en": "What would you like your microagent to remember?", @@ -205,7 +217,8 @@ "fr": "Que souhaitez-vous que votre micro-agent se souvienne ?", "tr": "Mikro ajanınızın neyi hatırlamasını istersiniz?", "de": "Was soll sich Ihr Microagent merken?", - "uk": "Що ви хочете, щоб ваш мікроагент запам'ятав?" + "uk": "Що ви хочете, щоб ваш мікроагент запам'ятав?", + "ca": "Què vols que el teu microagent recordi?" }, "MICROAGENT$ADD_TRIGGERS": { "en": "Add triggers for the microagent", @@ -221,7 +234,8 @@ "fr": "Ajouter des déclencheurs pour le micro-agent", "tr": "Mikro ajan için tetikleyiciler ekleyin", "de": "Auslöser für den Microagent hinzufügen", - "uk": "Додати тригери для мікроагента" + "uk": "Додати тригери для мікроагента", + "ca": "Afegeix disparadors per al microagent" }, "MICROAGENT$WAIT_FOR_RUNTIME": { "en": "Please wait for the runtime to be active.", @@ -237,7 +251,8 @@ "fr": "Veuillez attendre que le runtime soit actif.", "tr": "Lütfen çalışma zamanının aktif olmasını bekleyin.", "de": "Bitte warten Sie, bis die Laufzeitumgebung aktiv ist.", - "uk": "Будь ласка, зачекайте, поки середовище виконання стане активним." + "uk": "Будь ласка, зачекайте, поки середовище виконання стане активним.", + "ca": "Espereu que l'entorn d'execució estigui actiu." }, "FORGEJO$TOKEN_LABEL": { "en": "Forgejo Personal Access Token", @@ -253,7 +268,8 @@ "fr": "Jeton d'accès personnel Forgejo", "tr": "Forgejo kişisel erişim belirteci", "de": "Forgejo persönliches Zugriffstoken", - "uk": "Персональний токен доступу Forgejo" + "uk": "Персональний токен доступу Forgejo", + "ca": "Token d'accés personal de Forgejo" }, "FORGEJO$HOST_LABEL": { "en": "Forgejo Host (domain)", @@ -269,7 +285,8 @@ "fr": "Hôte Forgejo (domaine)", "tr": "Forgejo ana makinesi (alan adı)", "de": "Forgejo Host (Domain)", - "uk": "Хост Forgejo (домен)" + "uk": "Хост Forgejo (домен)", + "ca": "Servidor de Forgejo (domini)" }, "MICROAGENT$ADDING_CONTEXT": { "en": "OpenHands is adding this new context to your respository. We'll let you know when the pull request is ready.", @@ -285,7 +302,8 @@ "fr": "OpenHands ajoute ce nouveau contexte à votre dépôt. Nous vous informerons lorsque la pull request sera prête.", "tr": "OpenHands bu yeni bağlamı deponuza ekliyor. Çekme isteği hazır olduğunda size haber vereceğiz.", "de": "OpenHands fügt diesen neuen Kontext zu Ihrem Repository hinzu. Wir informieren Sie, wenn der Pull Request bereit ist.", - "uk": "OpenHands додає цей новий контекст до вашого репозиторію. Ми повідомимо вас, коли запит на витягування буде готовий." + "uk": "OpenHands додає цей новий контекст до вашого репозиторію. Ми повідомимо вас, коли запит на витягування буде готовий.", + "ca": "OpenHands està afegint aquest nou context al vostre repositori. Us avisarem quan la sol·licitud de canvis estigui preparada." }, "MICROAGENT$VIEW_CONVERSATION": { "en": "View Conversation", @@ -301,7 +319,8 @@ "fr": "Voir la conversation", "tr": "Konuşmayı Görüntüle", "de": "Konversation anzeigen", - "uk": "Переглянути розмову" + "uk": "Переглянути розмову", + "ca": "Veure la conversa" }, "MICROAGENT$SUCCESS_PR_READY": { "en": "Success! Your microagent pull request is ready.", @@ -317,7 +336,8 @@ "fr": "Succès ! Votre pull request de micro-agent est prête.", "tr": "Başarılı! Mikro ajan çekme isteğiniz hazır.", "de": "Erfolg! Ihr Microagent Pull Request ist bereit.", - "uk": "Успіх! Ваш запит на витягування мікроагента готовий." + "uk": "Успіх! Ваш запит на витягування мікроагента готовий.", + "ca": "Fet! La sol·licitud de canvis del microagent està preparada." }, "MICROAGENT$STATUS_CREATING": { "en": "Modifying microagent...", @@ -333,7 +353,8 @@ "fr": "Modification du micro-agent en cours...", "tr": "Mikro ajan değiştiriliyor...", "de": "Microagent wird geändert...", - "uk": "Зміна мікроагента..." + "uk": "Зміна мікроагента...", + "ca": "Modificant el microagent..." }, "MICROAGENT$STATUS_OPENING_PR": { "en": "Opening PR", @@ -349,7 +370,8 @@ "fr": "Ouverture de la PR", "tr": "PR açılıyor", "de": "PR wird geöffnet", - "uk": "Відкриття PR" + "uk": "Відкриття PR", + "ca": "Obrint la PR" }, "MICROAGENT$STATUS_COMPLETED": { "en": "View microagent update", @@ -365,7 +387,8 @@ "fr": "Voir la mise à jour du micro-agent", "tr": "Mikro ajan güncellemesini görüntüle", "de": "Microagent-Update anzeigen", - "uk": "Переглянути оновлення мікроагента" + "uk": "Переглянути оновлення мікроагента", + "ca": "Veure l'actualització del microagent" }, "MICROAGENT$STATUS_ERROR": { "en": "Microagent encountered an error", @@ -381,7 +404,8 @@ "fr": "Le micro-agent a rencontré une erreur", "tr": "Mikro ajan bir hatayla karşılaştı", "de": "Microagent ist auf einen Fehler gestoßen", - "uk": "Мікроагент зіткнувся з помилкою" + "uk": "Мікроагент зіткнувся з помилкою", + "ca": "El microagent ha trobat un error" }, "MICROAGENT$VIEW_YOUR_PR": { "en": "View your PR", @@ -397,7 +421,8 @@ "fr": "Voir votre PR", "tr": "PR'ınızı görüntüleyin", "de": "Ihre PR anzeigen", - "uk": "Переглянути ваш PR" + "uk": "Переглянути ваш PR", + "ca": "Veure la teva PR" }, "MICROAGENT$DESCRIBE_WHAT_TO_ADD": { "en": "Describe what you want to add to the Microagent...", @@ -413,7 +438,8 @@ "fr": "Décrivez ce que vous souhaitez ajouter au micro-agent...", "tr": "Mikro ajana eklemek istediğinizi açıklayın...", "de": "Beschreiben Sie, was Sie zum Microagent hinzufügen möchten...", - "uk": "Опишіть, що ви хочете додати до мікроагента..." + "uk": "Опишіть, що ви хочете додати до мікроагента...", + "ca": "Descriu el que vols afegir al Microagent..." }, "MICROAGENT$SELECT_FILE_OR_CUSTOM": { "en": "Select a microagent file or enter a custom value", @@ -429,7 +455,8 @@ "fr": "Sélectionnez un fichier micro-agent ou entrez une valeur personnalisée", "tr": "Bir mikro ajan dosyası seçin veya özel bir değer girin", "de": "Wählen Sie eine Microagent-Datei aus oder geben Sie einen benutzerdefinierten Wert ein", - "uk": "Виберіть файл мікроагента або введіть власне значення" + "uk": "Виберіть файл мікроагента або введіть власне значення", + "ca": "Selecciona un fitxer de microagent o introdueix un valor personalitzat" }, "MICROAGENT$TYPE_TRIGGER_SPACE": { "en": "Type a trigger and press Space to add it", @@ -445,7 +472,8 @@ "fr": "Tapez un déclencheur et appuyez sur Espace pour l'ajouter", "tr": "Bir tetikleyici yazın ve eklemek için Boşluk tuşuna basın", "de": "Geben Sie einen Auslöser ein und drücken Sie die Leertaste, um ihn hinzuzufügen", - "uk": "Введіть тригер і натисніть пробіл, щоб додати його" + "uk": "Введіть тригер і натисніть пробіл, щоб додати його", + "ca": "Escriu un disparador i prem Espai per afegir-lo" }, "MICROAGENT$LOADING_PROMPT": { "en": "Loading prompt...", @@ -461,7 +489,8 @@ "fr": "Chargement du prompt...", "tr": "İstem yükleniyor...", "de": "Prompt wird geladen...", - "uk": "Завантаження підказки..." + "uk": "Завантаження підказки...", + "ca": "Carregant el missatge..." }, "MICROAGENT$CANCEL": { "en": "Cancel", @@ -477,7 +506,8 @@ "fr": "Annuler", "tr": "İptal", "de": "Abbrechen", - "uk": "Скасувати" + "uk": "Скасувати", + "ca": "Cancel·la" }, "MICROAGENT$LAUNCH": { "en": "Launch", @@ -493,7 +523,8 @@ "fr": "Lancer", "tr": "Başlat", "de": "Starten", - "uk": "Запустити" + "uk": "Запустити", + "ca": "Llança" }, "STATUS$WEBSOCKET_CLOSED": { "en": "The WebSocket connection was closed.", @@ -509,7 +540,8 @@ "fr": "La connexion WebSocket a été fermée.", "tr": "WebSocket bağlantısı kapatıldı.", "de": "Die WebSocket-Verbindung wurde geschlossen.", - "uk": "З'єднання WebSocket було закрито." + "uk": "З'єднання WebSocket було закрито.", + "ca": "La connexió WebSocket s'ha tancat." }, "HOME$LAUNCH_FROM_SCRATCH": { "en": "Launch from Scratch", @@ -525,7 +557,8 @@ "fr": "Démarrer de zéro", "tr": "Sıfırdan başla", "de": "Von Grund auf starten", - "uk": "Почати з нуля" + "uk": "Почати з нуля", + "ca": "Comença des de zero" }, "HOME$READ_THIS": { "en": "Read this", @@ -541,7 +574,8 @@ "fr": "Lire ceci", "tr": "Bunu oku", "de": "Lies dies", - "uk": "Прочитайте це" + "uk": "Прочитайте це", + "ca": "Llegeix això" }, "AUTH$LOGGING_BACK_IN": { "en": "Logging back into OpenHands...", @@ -557,7 +591,8 @@ "fr": "Reconnexion à OpenHands...", "tr": "OpenHands'e yeniden giriş yapılıyor...", "de": "Erneute Anmeldung bei OpenHands...", - "uk": "Повторний вхід до OpenHands..." + "uk": "Повторний вхід до OpenHands...", + "ca": "Tornant a iniciar sessió a OpenHands..." }, "SECURITY$LOW_RISK": { "en": "Risk: Low", @@ -573,7 +608,8 @@ "fr": "Risque : Faible", "tr": "Risk: Düşük", "de": "Risiko: Gering", - "uk": "Ризик: Низький" + "uk": "Ризик: Низький", + "ca": "Risc: Baix" }, "SECURITY$MEDIUM_RISK": { "en": "Risk: Medium", @@ -589,7 +625,8 @@ "fr": "Risque : Moyen", "tr": "Risk: Orta", "de": "Risiko: Mittel", - "uk": "Ризик: Середній" + "uk": "Ризик: Середній", + "ca": "Risc: Mitjà" }, "SECURITY$HIGH_RISK": { "en": "Risk: High", @@ -605,7 +642,8 @@ "fr": "Risque : Élevé", "tr": "Risk: Yüksek", "de": "Risiko: Hoch", - "uk": "Ризик: Високий" + "uk": "Ризик: Високий", + "ca": "Risc: Alt" }, "SECURITY$UNKNOWN_RISK": { "en": "Risk: Unknown", @@ -621,7 +659,8 @@ "fr": "Risque : Inconnu", "tr": "Risk: Bilinmeyen", "de": "Risiko: Unbekannt", - "uk": "Ризик: Невідомий" + "uk": "Ризик: Невідомий", + "ca": "Risc: Desconegut" }, "FINISH$TASK_COMPLETED_SUCCESSFULLY": { "en": "I believe that the task was **completed successfully**.", @@ -637,7 +676,8 @@ "fr": "Je pense que la tâche a été **accomplie avec succès**.", "tr": "Görevin **başarıyla tamamlandığına** inanıyorum.", "de": "Ich glaube, dass die Aufgabe **erfolgreich abgeschlossen** wurde.", - "uk": "Я вважаю, що завдання було **успішно виконано**." + "uk": "Я вважаю, що завдання було **успішно виконано**.", + "ca": "Crec que la tasca s'ha **completat correctament**." }, "FINISH$TASK_NOT_COMPLETED": { "en": "I believe that the task was **not completed**.", @@ -653,7 +693,8 @@ "fr": "Je pense que la tâche **n'a pas été accomplie**.", "tr": "Görevin **tamamlanmadığına** inanıyorum.", "de": "Ich glaube, dass die Aufgabe **nicht abgeschlossen** wurde.", - "uk": "Я вважаю, що завдання **не було виконано**." + "uk": "Я вважаю, що завдання **не було виконано**.", + "ca": "Crec que la tasca **no s'ha completat**." }, "FINISH$TASK_COMPLETED_PARTIALLY": { "en": "I believe that the task was **completed partially**.", @@ -669,7 +710,8 @@ "fr": "Je pense que la tâche a été **partiellement accomplie**.", "tr": "Görevin **kısmen tamamlandığına** inanıyorum.", "de": "Ich glaube, dass die Aufgabe **teilweise abgeschlossen** wurde.", - "uk": "Я вважаю, що завдання було **частково виконано**." + "uk": "Я вважаю, що завдання було **частково виконано**.", + "ca": "Crec que la tasca s'ha **completat parcialment**." }, "EVENT$UNKNOWN_EVENT": { "en": "Unknown event", @@ -685,7 +727,8 @@ "fr": "Événement inconnu", "tr": "Bilinmeyen olay", "de": "Unbekanntes Ereignis", - "uk": "Невідома подія" + "uk": "Невідома подія", + "ca": "Esdeveniment desconegut" }, "OBSERVATION$COMMAND_NO_OUTPUT": { "en": "[Command finished execution with no output]", @@ -701,7 +744,8 @@ "fr": "[La commande s'est terminée sans sortie]", "tr": "[Komut çıktı olmadan yürütmeyi tamamladı]", "de": "[Befehl wurde ohne Ausgabe ausgeführt]", - "uk": "[Команда завершила виконання без виводу]" + "uk": "[Команда завершила виконання без виводу]", + "ca": "[La comanda ha finalitzat l'execució sense cap sortida]" }, "OBSERVATION$MCP_NO_OUTPUT": { "en": "[MCP Tool finished execution with no output]", @@ -717,7 +761,8 @@ "fr": "[L'outil MCP s'est terminé sans sortie]", "tr": "[MCP Aracı çıktı olmadan yürütmeyi tamamladı]", "de": "[MCP-Tool wurde ohne Ausgabe ausgeführt]", - "uk": "[Інструмент MCP завершив виконання без виводу]" + "uk": "[Інструмент MCP завершив виконання без виводу]", + "ca": "[L'eina MCP ha finalitzat l'execució sense cap sortida]" }, "OBSERVATION$TASK_TRACKING_NO_OUTPUT": { "en": "[Task tracking completed with no output]", @@ -733,7 +778,8 @@ "fr": "[Suivi des tâches terminé sans sortie]", "tr": "[Görev takibi çıktı olmadan tamamlandı]", "de": "[Aufgabenverfolgung ohne Ausgabe abgeschlossen]", - "uk": "[Відстеження завдань завершено без виводу]" + "uk": "[Відстеження завдань завершено без виводу]", + "ca": "[El seguiment de tasques ha finalitzat sense cap sortida]" }, "MCP_OBSERVATION$ARGUMENTS": { "en": "Arguments", @@ -749,7 +795,8 @@ "fr": "Arguments", "tr": "Argümanlar", "de": "Argumente", - "uk": "Аргументи" + "uk": "Аргументи", + "ca": "Arguments" }, "MCP_OBSERVATION$OUTPUT": { "en": "Output", @@ -765,7 +812,8 @@ "fr": "Sortie", "tr": "Çıktı", "de": "Ausgabe", - "uk": "Вивід" + "uk": "Вивід", + "ca": "Sortida" }, "TASK_TRACKING_OBSERVATION$TASK_LIST": { "en": "Task List", @@ -781,7 +829,8 @@ "fr": "Liste des tâches", "tr": "Görev listesi", "de": "Aufgabenliste", - "uk": "Список завдань" + "uk": "Список завдань", + "ca": "Llista de tasques" }, "TASK_TRACKING_OBSERVATION$OUTPUT": { "en": "Output", @@ -797,7 +846,8 @@ "fr": "Sortie", "tr": "Çıktı", "de": "Ausgabe", - "uk": "Вивід" + "uk": "Вивід", + "ca": "Sortida" }, "TASK_TRACKING_OBSERVATION$TASK_ID": { "en": "ID", @@ -813,7 +863,8 @@ "fr": "ID", "tr": "ID", "de": "ID", - "uk": "ID" + "uk": "ID", + "ca": "ID" }, "TASK_TRACKING_OBSERVATION$TASK_NOTES": { "en": "Notes", @@ -829,7 +880,8 @@ "fr": "Notes", "tr": "Notlar", "de": "Notizen", - "uk": "Примітки" + "uk": "Примітки", + "ca": "Notes" }, "TASK_TRACKING_OBSERVATION$RESULT": { "en": "Result", @@ -845,7 +897,8 @@ "fr": "Résultat", "tr": "Sonuç", "de": "Ergebnis", - "uk": "Результат" + "uk": "Результат", + "ca": "Resultat" }, "OBSERVATION$ERROR_PREFIX": { "en": "error:", @@ -861,7 +914,8 @@ "fr": "erreur:", "tr": "hata:", "de": "Fehler:", - "uk": "помилка:" + "uk": "помилка:", + "ca": "error:" }, "TASK$ADDRESSING_TASK": { "en": "Addressing task...", @@ -877,7 +931,8 @@ "fr": "Traitement de la tâche...", "tr": "Görev ele alınıyor...", "de": "Aufgabe wird bearbeitet...", - "uk": "Вирішення завдання..." + "uk": "Вирішення завдання...", + "ca": "Tractant la tasca..." }, "SECRETS$SECRET_VALUE_REQUIRED": { "en": "Secret value is required", @@ -893,7 +948,8 @@ "fr": "La valeur du secret est requise", "tr": "Gizli değer gereklidir", "de": "Geheimer Wert ist erforderlich", - "uk": "Значення секрету є обов'язковим" + "uk": "Значення секрету є обов'язковим", + "ca": "El valor del secret és obligatori" }, "SECRETS$ADD_SECRET": { "en": "Add secret", @@ -909,7 +965,8 @@ "fr": "Ajouter un secret", "tr": "Gizli ekle", "de": "Geheimnis hinzufügen", - "uk": "Додати секрет" + "uk": "Додати секрет", + "ca": "Afegeix un secret" }, "SECRETS$EDIT_SECRET": { "en": "Edit secret", @@ -925,7 +982,8 @@ "fr": "Modifier le secret", "tr": "Gizliyi düzenle", "de": "Geheimnis bearbeiten", - "uk": "Редагувати секрет" + "uk": "Редагувати секрет", + "ca": "Edita el secret" }, "SECRETS$ADD_NEW_SECRET": { "en": "Add a new secret", @@ -941,7 +999,8 @@ "fr": "Ajouter un nouveau secret", "tr": "Yeni bir gizli ekle", "de": "Neues Geheimnis hinzufügen", - "uk": "Додати новий секрет" + "uk": "Додати новий секрет", + "ca": "Afegeix un nou secret" }, "SECRETS$CONFIRM_DELETE_KEY": { "en": "Are you sure you want to delete this key?", @@ -957,7 +1016,8 @@ "fr": "Êtes-vous sûr de vouloir supprimer cette clé ?", "tr": "Bu anahtarı silmek istediğinizden emin misiniz?", "de": "Sind Sie sicher, dass Sie diesen Schlüssel löschen möchten?", - "uk": "Ви впевнені, що хочете видалити цей ключ?" + "uk": "Ви впевнені, що хочете видалити цей ключ?", + "ca": "Esteu segur que voleu eliminar aquesta clau?" }, "SETTINGS$MCP_TITLE": { "en": "Model Context Protocol (MCP)", @@ -973,7 +1033,8 @@ "fr": "Protocole de Contexte de Modèle (MCP)", "tr": "Model Bağlam Protokolü (MCP)", "de": "Modellkontextprotokoll (MCP)", - "uk": "Протокол контексту моделі (MCP)" + "uk": "Протокол контексту моделі (MCP)", + "ca": "Protocol de Context de Model (MCP)" }, "SETTINGS$MCP_DESCRIPTION": { "en": "Configure MCP servers for enhanced model capabilities", @@ -989,7 +1050,8 @@ "fr": "Configurez les serveurs MCP pour des capacités de modèle améliorées", "tr": "Gelişmiş model yetenekleri için MCP sunucularını yapılandırın", "de": "Konfigurieren Sie MCP-Server für erweiterte Modellfunktionen", - "uk": "Налаштуйте сервери MCP для розширених можливостей моделі" + "uk": "Налаштуйте сервери MCP для розширених можливостей моделі", + "ca": "Configura els servidors MCP per a capacitats de model millorades" }, "SETTINGS$NAV_MCP": { "en": "MCP", @@ -1005,7 +1067,8 @@ "fr": "MCP", "tr": "MCP", "de": "MCP", - "uk": "MCP" + "uk": "MCP", + "ca": "MCP" }, "SETTINGS$MCP_CONFIGURATION": { "en": "MCP Configuration", @@ -1021,7 +1084,8 @@ "fr": "Configuration MCP", "tr": "MCP Yapılandırması", "de": "MCP-Konfiguration", - "uk": "Налаштування MCP" + "uk": "Налаштування MCP", + "ca": "Configuració MCP" }, "SETTINGS$MCP_EDIT_CONFIGURATION": { "en": "Edit Configuration", @@ -1037,7 +1101,8 @@ "fr": "Modifier la configuration", "tr": "Yapılandırmayı Düzenle", "de": "Konfiguration bearbeiten", - "uk": "Редагувати налаштування" + "uk": "Редагувати налаштування", + "ca": "Edita la configuració" }, "SETTINGS$MCP_PREVIEW_CHANGES": { "en": "Preview Changes", @@ -1053,7 +1118,8 @@ "fr": "Aperçu des modifications", "tr": "Değişiklikleri Önizle", "de": "Änderungen anzeigen", - "uk": "Переглянути зміни" + "uk": "Переглянути зміни", + "ca": "Previsualitza els canvis" }, "SETTINGS$MCP_CONFIG_DESCRIPTION": { "en": "Edit the JSON configuration for MCP servers below. The configuration must include both sse_servers and stdio_servers arrays. For full configuration details and integration examples, see the documentation.", @@ -1069,7 +1135,8 @@ "fr": "Modifiez la configuration JSON pour les serveurs MCP ci-dessous. La configuration doit inclure à la fois les tableaux sse_servers et stdio_servers. Pour plus de détails sur la configuration et des exemples d'intégration, voir la documentation.", "tr": "Aşağıdaki MCP sunucuları için JSON yapılandırmasını düzenleyin. Yapılandırma hem sse_servers hem de stdio_servers dizilerini içermelidir. Tam yapılandırma ayrıntıları ve entegrasyon örnekleri için belgeler'e bakın.", "de": "Bearbeiten Sie die JSON-Konfiguration für MCP-Server unten. Die Konfiguration muss sowohl sse_servers- als auch stdio_servers-Arrays enthalten. Weitere Konfigurationsdetails und Integrationsbeispiele finden Sie in der Dokumentation.", - "uk": "Відредагуйте JSON-конфігурацію для серверів MCP нижче. Конфігурація повинна включати масиви sse_servers та stdio_servers. Повну інформацію про конфігурацію та приклади інтеграції дивіться в документації." + "uk": "Відредагуйте JSON-конфігурацію для серверів MCP нижче. Конфігурація повинна включати масиви sse_servers та stdio_servers. Повну інформацію про конфігурацію та приклади інтеграції дивіться в документації.", + "ca": "Editeu la configuració JSON per als servidors MCP a continuació. La configuració ha d'incloure els arrays sse_servers i stdio_servers. Per a informació completa sobre la configuració i exemples d'integració, consulteu la documentació." }, "SETTINGS$MCP_CONFIG_ERROR": { "en": "Error:", @@ -1085,7 +1152,8 @@ "fr": "Erreur :", "tr": "Hata:", "de": "Fehler:", - "uk": "Помилка:" + "uk": "Помилка:", + "ca": "Error:" }, "SETTINGS$MCP_CONFIG_EXAMPLE": { "en": "Example:", @@ -1101,7 +1169,8 @@ "fr": "Exemple :", "tr": "Örnek:", "de": "Beispiel:", - "uk": "Приклад:" + "uk": "Приклад:", + "ca": "Exemple:" }, "SETTINGS$MCP_NO_SERVERS_CONFIGURED": { "en": "No MCP servers are currently configured. Click \"Edit Configuration\" to add servers.", @@ -1117,7 +1186,8 @@ "fr": "Aucun serveur MCP n'est actuellement configuré. Cliquez sur \"Modifier la configuration\" pour ajouter des serveurs.", "tr": "Şu anda yapılandırılmış MCP sunucusu yok. Sunucu eklemek için \"Yapılandırmayı Düzenle\"yi tıklayın.", "de": "Derzeit sind keine MCP-Server konfiguriert. Klicken Sie auf \"Konfiguration bearbeiten\", um Server hinzuzufügen.", - "uk": "Наразі не налаштовано жодного сервера MCP. Натисніть \"Редагувати налаштування\", щоб додати сервери." + "uk": "Наразі не налаштовано жодного сервера MCP. Натисніть \"Редагувати налаштування\", щоб додати сервери.", + "ca": "No hi ha cap servidor MCP configurat. Feu clic a \"Edita la configuració\" per afegir servidors." }, "SETTINGS$MCP_SSE_SERVERS": { "en": "SSE Servers", @@ -1133,7 +1203,8 @@ "fr": "Serveurs SSE", "tr": "SSE Sunucuları", "de": "SSE-Server", - "uk": "Сервери SSE" + "uk": "Сервери SSE", + "ca": "Servidors SSE" }, "SETTINGS$MCP_STDIO_SERVERS": { "en": "Stdio Servers", @@ -1149,7 +1220,8 @@ "fr": "Serveurs Stdio", "tr": "Stdio Sunucuları", "de": "Stdio-Server", - "uk": "Сервери Stdio" + "uk": "Сервери Stdio", + "ca": "Servidors Stdio" }, "SETTINGS$MCP_API_KEY": { "en": "API Key", @@ -1165,7 +1237,8 @@ "fr": "Clé API", "tr": "API Anahtarı", "de": "API-Schlüssel", - "uk": "API-ключ" + "uk": "API-ключ", + "ca": "Clau d'API" }, "SETTINGS$MCP_API_KEY_NOT_SET": { "en": "Not set", @@ -1181,7 +1254,8 @@ "fr": "Non défini", "tr": "Ayarlanmadı", "de": "Nicht festgelegt", - "uk": "Не встановлено" + "uk": "Не встановлено", + "ca": "No establert" }, "SETTINGS$MCP_COMMAND": { "en": "Command", @@ -1197,7 +1271,8 @@ "fr": "Commande", "tr": "Komut", "de": "Befehl", - "uk": "Команда" + "uk": "Команда", + "ca": "Comanda" }, "SETTINGS$MCP_ARGS": { "en": "Args", @@ -1213,7 +1288,8 @@ "fr": "Arguments", "tr": "Argümanlar", "de": "Argumente", - "uk": "Аргументи" + "uk": "Аргументи", + "ca": "Arguments" }, "SETTINGS$MCP_ENV": { "en": "Env", @@ -1229,7 +1305,8 @@ "fr": "Environnement", "tr": "Ortam", "de": "Umgebung", - "uk": "Середовище" + "uk": "Середовище", + "ca": "Entorn" }, "SETTINGS$MCP_NAME": { "en": "Name", @@ -1245,7 +1322,8 @@ "fr": "Nom", "tr": "İsim", "de": "Name", - "uk": "Назва" + "uk": "Назва", + "ca": "Nom" }, "SETTINGS$MCP_URL": { "en": "URL", @@ -1261,7 +1339,8 @@ "fr": "URL", "tr": "URL", "de": "URL", - "uk": "URL" + "uk": "URL", + "ca": "URL" }, "SETTINGS$MCP_LEARN_MORE": { "en": "Learn more", @@ -1277,7 +1356,8 @@ "fr": "En savoir plus", "tr": "Daha fazla bilgi", "de": "Mehr erfahren", - "uk": "Дізнайтеся більше" + "uk": "Дізнайтеся більше", + "ca": "Aprèn-ne més" }, "SETTINGS$MCP_ERROR_SSE_ARRAY": { "en": "sse_servers must be an array", @@ -1293,7 +1373,8 @@ "fr": "sse_servers doit être un tableau", "tr": "sse_servers bir dizi olmalıdır", "de": "sse_servers muss ein Array sein", - "uk": "sse_servers повинна бути масивом" + "uk": "sse_servers повинна бути масивом", + "ca": "sse_servers ha de ser un array" }, "SETTINGS$MCP_ERROR_STDIO_ARRAY": { "en": "stdio_servers must be an array", @@ -1309,7 +1390,8 @@ "fr": "stdio_servers doit être un tableau", "tr": "stdio_servers bir dizi olmalıdır", "de": "stdio_servers muss ein Array sein", - "uk": "stdio_servers повинна бути масивом" + "uk": "stdio_servers повинна бути масивом", + "ca": "stdio_servers ha de ser un array" }, "SETTINGS$MCP_ERROR_SSE_URL": { "en": "Each SSE server must be a string URL or have a url property", @@ -1325,7 +1407,8 @@ "fr": "Chaque serveur SSE doit être une URL de chaîne ou avoir une propriété url", "tr": "Her SSE sunucusu bir dize URL'si olmalı veya bir url özelliğine sahip olmalıdır", "de": "Jeder SSE-Server muss eine String-URL sein oder eine URL-Eigenschaft haben", - "uk": "Кожний SSE сервер повинен бути строкою URL або мати url властивість" + "uk": "Кожний SSE сервер повинен бути строкою URL або мати url властивість", + "ca": "Cada servidor SSE ha de ser una URL de cadena o tenir una propietat url" }, "SETTINGS$MCP_ERROR_STDIO_PROPS": { "en": "Each stdio server must have name and command properties", @@ -1341,7 +1424,8 @@ "fr": "Chaque serveur stdio doit avoir les propriétés name et command", "tr": "Her stdio sunucusu name ve command özelliklerine sahip olmalıdır", "de": "Jeder stdio-Server muss die Eigenschaften name und command haben", - "uk": "Кожний stdio сервер повинен мати ім'я та командні властивості" + "uk": "Кожний stdio сервер повинен мати ім'я та командні властивості", + "ca": "Cada servidor stdio ha de tenir les propietats name i command" }, "SETTINGS$MCP_ERROR_INVALID_JSON": { "en": "Invalid JSON", @@ -1357,7 +1441,8 @@ "fr": "JSON invalide", "tr": "Geçersiz JSON", "de": "Ungültiges JSON", - "uk": "Невірний JSON" + "uk": "Невірний JSON", + "ca": "JSON no vàlid" }, "HOME$CONNECT_PROVIDER_MESSAGE": { "en": "To get started with suggested tasks, please connect your GitHub, GitLab, Bitbucket, or Azure DevOps account.", @@ -1373,7 +1458,8 @@ "fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub, GitLab, Bitbucket ou Azure DevOps.", "tr": "Önerilen görevlerle başlamak için lütfen GitHub, GitLab, Bitbucket veya Azure DevOps hesabınızı bağlayın.", "de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub-, GitLab-, Bitbucket- oder Azure DevOps-Konto.", - "uk": "Щоб розпочати роботу з запропонованими завданнями, підключіть свій обліковий запис GitHub, GitLab, Bitbucket або Azure DevOps." + "uk": "Щоб розпочати роботу з запропонованими завданнями, підключіть свій обліковий запис GitHub, GitLab, Bitbucket або Azure DevOps.", + "ca": "Per començar amb les tasques suggerides, connecteu el vostre compte de GitHub, GitLab, Bitbucket o Azure DevOps." }, "HOME$LETS_START_BUILDING": { "en": "Let's Start Building!", @@ -1389,7 +1475,8 @@ "fr": "Commençons à construire !", "tr": "Hadi İnşa Etmeye Başlayalım!", "de": "Lass uns anfangen zu bauen!", - "uk": "Почнімо будувати!" + "uk": "Почнімо будувати!", + "ca": "Comencem a construir!" }, "HOME$OPENHANDS_DESCRIPTION": { "en": "OpenHands makes it easy to build and maintain software using AI-driven development.", @@ -1405,7 +1492,8 @@ "fr": "OpenHands facilite la création et la maintenance de logiciels grâce au développement piloté par l'IA.", "tr": "OpenHands, yapay zeka destekli geliştirme kullanarak yazılım oluşturmayı ve sürdürmeyi kolaylaştırır.", "de": "OpenHands macht es einfach, Software mit KI-gesteuerter Entwicklung zu erstellen und zu warten.", - "uk": "OpenHands спрощує створення та підтримку програмного забезпечення за допомогою розробки на основі штучного інтелекту." + "uk": "OpenHands спрощує створення та підтримку програмного забезпечення за допомогою розробки на основі штучного інтелекту.", + "ca": "OpenHands facilita la construcció i el manteniment de programari mitjançant el desenvolupament impulsat per IA." }, "HOME$NOT_SURE_HOW_TO_START": { "en": "Not sure how to start?", @@ -1421,7 +1509,8 @@ "fr": "Vous ne savez pas par où commencer ?", "tr": "Nasıl başlayacağınızdan emin değil misiniz?", "de": "Nicht sicher, wie man anfängt?", - "uk": "Не знаєте, як почати?" + "uk": "Не знаєте, як почати?", + "ca": "No esteu segur de com començar?" }, "HOME$CONNECT_TO_REPOSITORY": { "en": "Connect to a Repository", @@ -1437,7 +1526,8 @@ "fr": "Se connecter à un dépôt", "tr": "Bir Depoya Bağlan", "de": "Mit einem Repository verbinden", - "uk": "Підключіть до репозиторій" + "uk": "Підключіть до репозиторій", + "ca": "Connecta a un repositori" }, "HOME$CONNECT_TO_REPOSITORY_TOOLTIP": { "en": "You can enter a public GitHub URL if you'd like to work from a public repo instead", @@ -1453,7 +1543,8 @@ "fr": "Vous pouvez saisir une URL GitHub publique si vous souhaitez travailler à partir d'un dépôt public", "tr": "Bunun yerine genel bir repo'dan çalışmak istiyorsanız genel bir GitHub URL'si girebilirsiniz", "de": "Sie können eine öffentliche GitHub-URL eingeben, wenn Sie stattdessen von einem öffentlichen Repository arbeiten möchten", - "uk": "Ви можете ввести публічну GitHub URL, якщо хочете працювати з публічного репозиторію" + "uk": "Ви можете ввести публічну GitHub URL, якщо хочете працювати з публічного репозиторію", + "ca": "Podeu introduir una URL pública de GitHub si preferiu treballar des d'un repositori públic" }, "HOME$LOADING": { "en": "Loading...", @@ -1469,7 +1560,8 @@ "fr": "Chargement...", "tr": "Yükleniyor...", "de": "Wird geladen...", - "uk": "Завантаження..." + "uk": "Завантаження...", + "ca": "Carregant..." }, "HOME$LOADING_REPOSITORIES": { "en": "Loading repositories...", @@ -1485,7 +1577,8 @@ "fr": "Chargement des dépôts...", "tr": "Depolar yükleniyor...", "de": "Repositories werden geladen...", - "uk": "Завантаження репозиторіїв..." + "uk": "Завантаження репозиторіїв...", + "ca": "Carregant repositoris..." }, "HOME$SEARCHING_REPOSITORIES": { "en": "Searching repositories...", @@ -1501,7 +1594,8 @@ "fr": "Recherche de dépôts...", "tr": "Depolar aranıyor...", "de": "Repositories werden durchsucht...", - "uk": "Пошук репозиторіїв..." + "uk": "Пошук репозиторіїв...", + "ca": "Cercant repositoris..." }, "HOME$LOADING_MORE_REPOSITORIES": { "en": "Loading more repositories...", @@ -1517,7 +1611,8 @@ "fr": "Chargement de plus de dépôts...", "tr": "Daha fazla depolar yükleniyor...", "de": "Weitere Repositories werden geladen...", - "uk": "Завантаження більше репозиторіїв..." + "uk": "Завантаження більше репозиторіїв...", + "ca": "Carregant més repositoris..." }, "HOME$FAILED_TO_LOAD_REPOSITORIES": { "en": "Failed to load repositories", @@ -1533,7 +1628,8 @@ "fr": "Échec du chargement des dépôts", "tr": "Depolar yüklenemedi", "de": "Fehler beim Laden der Repositories", - "uk": "Не вдалося завантажити репозиторії" + "uk": "Не вдалося завантажити репозиторії", + "ca": "No s'han pogut carregar els repositoris" }, "HOME$LOADING_BRANCHES": { "en": "Loading branches...", @@ -1549,7 +1645,8 @@ "fr": "Chargement des branches...", "tr": "Dallar yükleniyor...", "de": "Lade Branches...", - "uk": "Завантаження гілок..." + "uk": "Завантаження гілок...", + "ca": "Carregant branques..." }, "HOME$FAILED_TO_LOAD_BRANCHES": { "en": "Failed to load branches", @@ -1565,7 +1662,8 @@ "fr": "Échec du chargement des branches", "tr": "Dallar yüklenemedi", "de": "Fehler beim Laden der Branches", - "uk": "Не вдалося завантажити гілки" + "uk": "Не вдалося завантажити гілки", + "ca": "No s'han pogut carregar les branques" }, "HOME$OPEN_ISSUE": { "en": "Open issue", @@ -1581,7 +1679,8 @@ "fr": "Problème ouvert", "tr": "Açık sorun", "de": "Offenes Problem", - "uk": "Повідомити про проблему" + "uk": "Повідомити про проблему", + "ca": "Problema obert" }, "HOME$FIX_FAILING_CHECKS": { "en": "Fix failing checks", @@ -1597,7 +1696,8 @@ "fr": "Corriger les vérifications échouées", "tr": "Başarısız kontrolleri düzelt", "de": "Fehlgeschlagene Prüfungen beheben", - "uk": "Виправити невдалі перевірки" + "uk": "Виправити невдалі перевірки", + "ca": "Corregeix les comprovacions fallides" }, "HOME$RESOLVE_MERGE_CONFLICTS": { "en": "Resolve merge conflicts", @@ -1613,7 +1713,8 @@ "fr": "Résoudre les conflits de fusion", "tr": "Birleştirme çakışmalarını çöz", "de": "Merge-Konflikte lösen", - "uk": "Вирішити конфлікти злиття" + "uk": "Вирішити конфлікти злиття", + "ca": "Resol els conflictes de fusió" }, "HOME$RESOLVE_UNRESOLVED_COMMENTS": { "en": "Resolve unresolved comments", @@ -1629,7 +1730,8 @@ "fr": "Résoudre les commentaires non résolus", "tr": "Çözülmemiş yorumları çöz", "de": "Ungelöste Kommentare beheben", - "uk": "Вирішити невирішені коментарі" + "uk": "Вирішити невирішені коментарі", + "ca": "Resol els comentaris no resolts" }, "HOME$LAUNCH": { "en": "Launch", @@ -1645,7 +1747,8 @@ "fr": "Lancer", "tr": "Başlat", "de": "Starten", - "uk": "Запуск" + "uk": "Запуск", + "ca": "Llança" }, "SETTINGS$ADVANCED": { "en": "Advanced", @@ -1661,7 +1764,8 @@ "fr": "Avancé", "tr": "Gelişmiş", "de": "Erweitert", - "uk": "Розширений" + "uk": "Розширений", + "ca": "Avançat" }, "SETTINGS$BASE_URL": { "en": "Base URL", @@ -1677,7 +1781,8 @@ "fr": "URL de base", "tr": "Temel URL", "de": "Basis-URL", - "uk": "Базовий URL" + "uk": "Базовий URL", + "ca": "URL base" }, "SETTINGS$AGENT": { "en": "Agent", @@ -1693,7 +1798,8 @@ "fr": "Agent", "tr": "Ajan", "de": "Agent", - "uk": "Агент" + "uk": "Агент", + "ca": "Agent" }, "SETTINGS$ENABLE_MEMORY_CONDENSATION": { "en": "Enable memory condensation", @@ -1709,7 +1815,8 @@ "fr": "Activer la condensation de mémoire", "tr": "Bellek yoğunlaştırmayı etkinleştir", "de": "Speicherkondensation aktivieren", - "uk": "Увімкнути конденсацію пам'яті" + "uk": "Увімкнути конденсацію пам'яті", + "ca": "Activa la condensació de memòria" }, "SETTINGS$CONDENSER_MAX_SIZE": { "en": "Memory condenser max history size", @@ -1725,7 +1832,8 @@ "fr": "Taille maximale de l'historique du condenseur de mémoire", "tr": "Bellek yoğunlaştırıcı maksimum geçmiş boyutu", "de": "Maximale Verlaufgröße des Speicherkondensators", - "uk": "Максимальний розмір історії конденсатора пам'яті" + "uk": "Максимальний розмір історії конденсатора пам'яті", + "ca": "Mida màxima de l'historial del condensador de memòria" }, "SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP": { "en": "After this many events, the condenser will summarize history. Minimum 20.", @@ -1741,7 +1849,8 @@ "fr": "Après ce nombre d'événements, le condenseur résumera l'historique. Minimum 20.", "tr": "Bu kadar olaydan sonra yoğunlaştırıcı geçmişi özetler. En az 20.", "de": "Nach so vielen Ereignissen fasst der Kondensator die Historie zusammen. Minimum 20.", - "uk": "Після цієї кількості подій конденсатор узагальнить історію. Мінімум 20." + "uk": "Після цієї кількості подій конденсатор узагальнить історію. Мінімум 20.", + "ca": "Després d'aquest nombre d'esdeveniments, el condensador resumirà l'historial. Mínim 20." }, "SETTINGS$LANGUAGE": { "en": "Language", @@ -1757,7 +1866,8 @@ "fr": "Langue", "tr": "Dil", "de": "Sprache", - "uk": "Мова" + "uk": "Мова", + "ca": "Idioma" }, "ACTION$PUSH_TO_BRANCH": { "en": "Push to Branch", @@ -1773,7 +1883,8 @@ "fr": "Pousser vers la branche", "tr": "Dala İtme", "de": "Zum Branch pushen", - "uk": "Надіслати у гілку" + "uk": "Надіслати у гілку", + "ca": "Publica a la branca" }, "ACTION$PUSH_CREATE_PR": { "en": "Push & Create PR", @@ -1789,7 +1900,8 @@ "fr": "Pousser et créer une PR", "tr": "İtme ve PR Oluştur", "de": "Pushen & PR erstellen", - "uk": "Надіслати & Створити PR" + "uk": "Надіслати & Створити PR", + "ca": "Publica i crea PR" }, "ACTION$PUSH_CHANGES_TO_PR": { "en": "Push changes to PR", @@ -1805,7 +1917,8 @@ "fr": "Pousser les modifications vers la PR", "tr": "Değişiklikleri PR'a İtme", "de": "Änderungen zum PR pushen", - "uk": "Надіслати зміти в PR" + "uk": "Надіслати зміти в PR", + "ca": "Publica els canvis a la PR" }, "ANALYTICS$TITLE": { "en": "Your Privacy Preferences", @@ -1821,7 +1934,8 @@ "fr": "Vos préférences de confidentialité", "tr": "Gizlilik Tercihleriniz", "de": "Ihre Datenschutzeinstellungen", - "uk": "Ваші налаштування конфіденційності" + "uk": "Ваші налаштування конфіденційності", + "ca": "Les vostres preferències de privadesa" }, "ANALYTICS$DESCRIPTION": { "en": "We use tools to understand how our application is used to improve your experience. You can enable or disable analytics. Your preferences will be stored and can be updated anytime.", @@ -1837,7 +1951,8 @@ "fr": "Nous utilisons des outils pour comprendre comment notre application est utilisée afin d'améliorer votre expérience. Vous pouvez activer ou désactiver les analyses. Vos préférences seront stockées et peuvent être mises à jour à tout moment.", "tr": "Uygulamamızın deneyiminizi geliştirmek için nasıl kullanıldığını anlamak için araçlar kullanıyoruz. Analitiği etkinleştirebilir veya devre dışı bırakabilirsiniz. Tercihleriniz saklanacak ve istediğiniz zaman güncellenebilir.", "de": "Wir verwenden Tools, um zu verstehen, wie unsere Anwendung genutzt wird, um Ihre Erfahrung zu verbessern. Sie können Analysen aktivieren oder deaktivieren. Ihre Einstellungen werden gespeichert und können jederzeit aktualisiert werden.", - "uk": "Ми використовуємо інструменти, щоб зрозуміти, як використовується наш додаток, для покращення вашого досвіду. Ви можете ввімкнути або вимкнути аналітику. Ваші налаштування будуть збережені та можуть бути оновлені будь-коли." + "uk": "Ми використовуємо інструменти, щоб зрозуміти, як використовується наш додаток, для покращення вашого досвіду. Ви можете ввімкнути або вимкнути аналітику. Ваші налаштування будуть збережені та можуть бути оновлені будь-коли.", + "ca": "Fem servir eines per entendre com s'utilitza la nostra aplicació i millorar la vostra experiència. Podeu activar o desactivar les analítiques. Les vostres preferències es desaran i es podran actualitzar en qualsevol moment." }, "ANALYTICS$SEND_ANONYMOUS_DATA": { "en": "Send anonymous usage data", @@ -1853,7 +1968,8 @@ "fr": "Envoyer des données d'utilisation anonymes", "tr": "Anonim kullanım verilerini gönder", "de": "Anonyme Nutzungsdaten senden", - "uk": "Надсилати анонімні дані про використання" + "uk": "Надсилати анонімні дані про використання", + "ca": "Envia dades d'ús anònimes" }, "ANALYTICS$CONFIRM_PREFERENCES": { "en": "Confirm Preferences", @@ -1869,7 +1985,8 @@ "fr": "Confirmer les préférences", "tr": "Tercihleri Onayla", "de": "Einstellungen bestätigen", - "uk": "Підтвердити налаштування" + "uk": "Підтвердити налаштування", + "ca": "Confirma les preferències" }, "SETTINGS$SAVING": { "en": "Saving...", @@ -1885,7 +2002,8 @@ "fr": "Enregistrement en cours...", "tr": "Kayıt yapılıyor...", "de": "Speichern...", - "uk": "Зберігаю..." + "uk": "Зберігаю...", + "ca": "Desant..." }, "SETTINGS$SAVE_CHANGES": { "en": "Save Changes", @@ -1901,7 +2019,8 @@ "fr": "Enregistrer les modifications", "tr": "Değişiklikleri Kaydet", "de": "Änderungen speichern", - "uk": "Зберегти зміни" + "uk": "Зберегти зміни", + "ca": "Desa els canvis" }, "SETTINGS$NAV_INTEGRATIONS": { "en": "Integrations", @@ -1917,7 +2036,8 @@ "fr": "Intégrations", "tr": "Entegrasyonlar", "de": "Integrationen", - "uk": "Інтеграції" + "uk": "Інтеграції", + "ca": "Integracions" }, "SETTINGS$NAV_APPLICATION": { "en": "Application", @@ -1933,7 +2053,8 @@ "fr": "Application", "tr": "Uygulama", "de": "Anwendung", - "uk": "Додаток" + "uk": "Додаток", + "ca": "Aplicació" }, "SETTINGS$NAV_BILLING": { "en": "Billing", @@ -1949,7 +2070,8 @@ "fr": "Facturation", "tr": "Faturalama", "de": "Abrechnung", - "uk": "Виставлення рахунків" + "uk": "Виставлення рахунків", + "ca": "Facturació" }, "SETTINGS$NAV_SECRETS": { "en": "Secrets", @@ -1965,7 +2087,8 @@ "fr": "Secrets", "tr": "Sırları", "de": "Geheimnisse", - "uk": "Секрети" + "uk": "Секрети", + "ca": "Secrets" }, "SETTINGS$NAV_API_KEYS": { "en": "API Keys", @@ -1981,7 +2104,8 @@ "fr": "Clés API", "tr": "API Anahtarları", "de": "API-Schlüssel", - "uk": "API ключі" + "uk": "API ключі", + "ca": "Claus d'API" }, "SETTINGS$GITHUB": { "en": "GitHub", @@ -1997,7 +2121,8 @@ "fr": "GitHub", "tr": "GitHub", "de": "GitHub", - "uk": "GitHub" + "uk": "GitHub", + "ca": "GitHub" }, "SETTINGS$AZURE_DEVOPS": { "en": "Azure DevOps", @@ -2013,7 +2138,8 @@ "fr": "Azure DevOps", "tr": "Azure DevOps", "de": "Azure DevOps", - "uk": "Azure DevOps" + "uk": "Azure DevOps", + "ca": "Azure DevOps" }, "SETTINGS$SLACK": { "en": "Slack", @@ -2029,7 +2155,8 @@ "fr": "Slack", "tr": "Slack", "de": "Slack", - "uk": "Slack" + "uk": "Slack", + "ca": "Slack" }, "COMMON$STATUS": { "en": "Status", @@ -2045,7 +2172,8 @@ "fr": "Statut", "tr": "Durum", "de": "Status", - "uk": "Статус" + "uk": "Статус", + "ca": "Estat" }, "SETTINGS$GITLAB_NOT_CONNECTED": { "en": "Not Connected", @@ -2061,7 +2189,8 @@ "fr": "Non connecté", "tr": "Bağlı değil", "de": "Nicht verbunden", - "uk": "Не підключено" + "uk": "Не підключено", + "ca": "No connectat" }, "SETTINGS$GITLAB_REINSTALL_WEBHOOK": { "en": "Reinstall Webhook", @@ -2077,7 +2206,8 @@ "fr": "Réinstaller le Webhook", "tr": "Webhook'u Yeniden Kur", "de": "Webhook neu installieren", - "uk": "Перевстановити Webhook" + "uk": "Перевстановити Webhook", + "ca": "Reinstal·la el Webhook" }, "SETTINGS$GITLAB_INSTALLING_WEBHOOK": { "en": "Installing GitLab webhook, please wait a few minutes.", @@ -2093,7 +2223,8 @@ "fr": "Installation du webhook GitLab, veuillez patienter quelques minutes.", "tr": "GitLab webhook'u yükleniyor, lütfen birkaç dakika bekleyin.", "de": "GitLab-Webhook wird installiert. Bitte warten Sie einige Minuten.", - "uk": "Встановлення GitLab webhook, зачекайте кілька хвилин." + "uk": "Встановлення GitLab webhook, зачекайте кілька хвилин.", + "ca": "S'està instal·lant el webhook de GitLab, espereu uns minuts." }, "SETTINGS$GITLAB": { "en": "GitLab", @@ -2109,7 +2240,8 @@ "fr": "GitLab", "tr": "GitLab", "de": "GitLab", - "uk": "GitLab" + "uk": "GitLab", + "ca": "GitLab" }, "SETTINGS$NAV_LLM": { "en": "LLM", @@ -2125,7 +2257,8 @@ "fr": "LLM", "tr": "LLM", "de": "LLM", - "uk": "LLM" + "uk": "LLM", + "ca": "LLM" }, "GIT$MERGE_REQUEST": { "en": "Merge Request", @@ -2141,7 +2274,8 @@ "fr": "Demande de fusion", "tr": "Birleştirme İsteği", "de": "Merge-Anfrage", - "uk": "Запит на злиття" + "uk": "Запит на злиття", + "ca": "Sol·licitud de fusió" }, "GIT$GITLAB_API": { "en": "GitLab API", @@ -2157,7 +2291,8 @@ "fr": "API GitLab", "tr": "GitLab API", "de": "GitLab API", - "uk": "GitLab API" + "uk": "GitLab API", + "ca": "API de GitLab" }, "GIT$PULL_REQUEST": { "en": "Pull Request", @@ -2173,7 +2308,8 @@ "fr": "Demande de tirage", "tr": "Çekme İsteği", "de": "Pull Request", - "uk": "Запит на злиття" + "uk": "Запит на злиття", + "ca": "Sol·licitud de canvis" }, "GIT$GITHUB_API": { "en": "GitHub API", @@ -2189,7 +2325,8 @@ "fr": "API GitHub", "tr": "GitHub API", "de": "GitHub API", - "uk": "GitHub API" + "uk": "GitHub API", + "ca": "API de GitHub" }, "BUTTON$COPY": { "en": "Copy to clipboard", @@ -2205,7 +2342,8 @@ "fr": "Copier dans le presse-papiers", "tr": "Panoya kopyala", "de": "In die Zwischenablage kopieren", - "uk": "Копіювати в буфер обміну" + "uk": "Копіювати в буфер обміну", + "ca": "Copia al porta-retalls" }, "BUTTON$COPIED": { "en": "Copied to clipboard", @@ -2221,7 +2359,8 @@ "fr": "Copié dans le presse-papiers", "tr": "Panoya kopyalandı", "de": "In die Zwischenablage kopiert", - "uk": "Copied to clipboard" + "uk": "Copied to clipboard", + "ca": "Copiat al porta-retalls" }, "APP$TITLE": { "en": "App", @@ -2237,7 +2376,8 @@ "fr": "App", "tr": "Uygulama", "de": "App", - "uk": "App" + "uk": "App", + "ca": "Aplicació" }, "BROWSER$TITLE": { "en": "Browser", @@ -2253,7 +2393,8 @@ "fr": "Navigateur", "tr": "Tarayıcı", "de": "Browser", - "uk": "Браузер" + "uk": "Браузер", + "ca": "Navegador" }, "BROWSER$EMPTY_MESSAGE": { "en": "If you tell OpenHands to start a web server, the app will appear here.", @@ -2269,7 +2410,8 @@ "fr": "Si vous demandez à OpenHands de démarrer un serveur web, l'application apparaîtra ici.", "tr": "OpenHands'e bir web sunucusu başlatmasını söylerseniz, uygulama burada görünecektir.", "de": "Wenn Sie OpenHands anweisen, einen Webserver zu starten, erscheint die App hier.", - "uk": "Якщо ви накажете OpenHands запустити вебсервер, програма з'явиться тут." + "uk": "Якщо ви накажете OpenHands запустити вебсервер, програма з'явиться тут.", + "ca": "Si li dieu a OpenHands que iniciï un servidor web, l'aplicació apareixerà aquí." }, "SETTINGS$TITLE": { "en": "Settings", @@ -2285,7 +2427,8 @@ "fr": "Paramètres", "tr": "Ayarlar", "de": "Einstellungen", - "uk": "Налаштування" + "uk": "Налаштування", + "ca": "Configuració" }, "CONVERSATION$START_NEW": { "en": "Start new conversation", @@ -2301,7 +2444,8 @@ "fr": "Démarrer une nouvelle conversation", "tr": "Yeni sohbet başlat", "de": "Neue Unterhaltung starten", - "uk": "Почати нову розмову" + "uk": "Почати нову розмову", + "ca": "Inicia una conversa nova" }, "CONVERSATION$REPOSITORY": { "en": "Repository", @@ -2317,7 +2461,8 @@ "fr": "Dépôt", "tr": "Depo", "de": "Repository", - "uk": "Репозиторій" + "uk": "Репозиторій", + "ca": "Repositori" }, "CONVERSATION$BRANCH": { "en": "Branch", @@ -2333,7 +2478,8 @@ "fr": "Branche", "tr": "Dal", "de": "Zweig", - "uk": "Гілка" + "uk": "Гілка", + "ca": "Branca" }, "CONVERSATION$GIT_PROVIDER": { "en": "Git Provider", @@ -2349,7 +2495,8 @@ "fr": "Fournisseur Git", "tr": "Git Sağlayıcısı", "de": "Git-Anbieter", - "uk": "Git-провайдер" + "uk": "Git-провайдер", + "ca": "Proveïdor de Git" }, "WORKSPACE$TERMINAL_TAB_LABEL": { "en": "Terminal", @@ -2365,7 +2512,8 @@ "fr": "Terminal", "tr": "Terminal", "ja": "ターミナル", - "uk": "Термінал" + "uk": "Термінал", + "ca": "Terminal" }, "WORKSPACE$BROWSER_TAB_LABEL": { "en": "Browser", @@ -2381,7 +2529,8 @@ "fr": "Navigateur", "tr": "Tarayıcı", "ja": "ブラウザ", - "uk": "Браузер" + "uk": "Браузер", + "ca": "Navegador" }, "WORKSPACE$JUPYTER_TAB_LABEL": { "en": "Jupyter", @@ -2397,7 +2546,8 @@ "fr": "Jupyter", "tr": "Jupyter", "ja": "Jupyter", - "uk": "Jupyter" + "uk": "Jupyter", + "ca": "Jupyter" }, "WORKSPACE$CODE_EDITOR_TAB_LABEL": { "en": "Code Editor", @@ -2413,7 +2563,8 @@ "fr": "Éditeur de code", "tr": "Kod Düzenleyici", "ja": "コードエディタ", - "uk": "Редактор коду" + "uk": "Редактор коду", + "ca": "Editor de codi" }, "WORKSPACE$TITLE": { "en": "Workspace", @@ -2429,7 +2580,8 @@ "fr": "Espace de travail", "tr": "Çalışma Alanı", "ja": "ワークスペース", - "uk": "Робочий простір" + "uk": "Робочий простір", + "ca": "Espai de treball" }, "TERMINAL$WAITING_FOR_CLIENT": { "en": "Waiting for client to become ready...", @@ -2445,7 +2597,8 @@ "ar": "في انتظار جاهزية العميل...", "fr": "En attente de la disponibilité du client...", "tr": "İstemcinin hazır olması bekleniyor...", - "uk": "Чекаємо на готовність клієнта..." + "uk": "Чекаємо на готовність клієнта...", + "ca": "Esperant que el client estigui preparat..." }, "CODE_EDITOR$FILE_SAVED_SUCCESSFULLY": { "en": "File saved successfully", @@ -2461,7 +2614,8 @@ "fr": "Fichier enregistré avec succès", "tr": "Dosya başarıyla kaydedildi", "ja": "ファイルが正常に保存されました", - "uk": "Файл успішно збережено" + "uk": "Файл успішно збережено", + "ca": "El fitxer s'ha desat correctament" }, "CODE_EDITOR$SAVING_LABEL": { "en": "Saving...", @@ -2477,7 +2631,8 @@ "fr": "Enregistrement en cours...", "tr": "Kayıt yapılıyor...", "ja": "保存中...", - "uk": "Збереження..." + "uk": "Збереження...", + "ca": "Desant..." }, "CODE_EDITOR$SAVE_LABEL": { "en": "Save", @@ -2493,7 +2648,8 @@ "fr": "Enregistrer", "tr": "Kayıt", "ja": "保存", - "uk": "Зберегти" + "uk": "Зберегти", + "ca": "Desa" }, "CODE_EDITOR$OPTIONS": { "en": "Options", @@ -2509,7 +2665,8 @@ "fr": "Options", "tr": "Seçenekler", "ja": "オプション", - "uk": "Опції" + "uk": "Опції", + "ca": "Opcions" }, "CODE_EDITOR$FILE_SAVE_ERROR": { "en": "An unknown error occurred while saving the file", @@ -2525,7 +2682,8 @@ "fr": "Une erreur inconnue s'est produite lors de l'enregistrement du fichier", "tr": "Dosya kaydedilirken bilinmeyen bir hata oluştu", "ja": "ファイルの保存中に不明なエラーが発生しました", - "uk": "Під час збереження файлу сталася невідома помилка" + "uk": "Під час збереження файлу сталася невідома помилка", + "ca": "S'ha produït un error desconegut en desar el fitxer" }, "CODE_EDITOR$EMPTY_MESSAGE": { "en": "No file selected.", @@ -2541,7 +2699,8 @@ "fr": "Aucun fichier sélectionné.", "tr": "Hiçbir dosya seçilmedi.", "ja": "ファイルが選択されていません。", - "uk": "Файл не вибрано." + "uk": "Файл не вибрано.", + "ca": "Cap fitxer seleccionat." }, "FILE_SERVICE$SELECT_FILE_ERROR": { "en": "Error selecting file. Please try again.", @@ -2557,7 +2716,8 @@ "fr": "Erreur lors de la sélection du fichier. Veuillez réessayer.", "tr": "Dosya seçiminde hata oluştu. Lütfen tekrar deneyin.", "ja": "ファイルの選択中にエラーが発生しました。もう一度お試しください。", - "uk": "Помилка вибору файлу. Спробуйте ще раз." + "uk": "Помилка вибору файлу. Спробуйте ще раз.", + "ca": "Error en seleccionar el fitxer. Torneu-ho a intentar." }, "FILE_SERVICE$UPLOAD_FILES_ERROR": { "en": "Error uploading files. Please try again.", @@ -2573,7 +2733,8 @@ "fr": "Erreur lors du téléchargement des fichiers. Veuillez réessayer.", "tr": "Dosyalar yüklenirken hata oluştu. Lütfen tekrar deneyin.", "ja": "ファイルのアップロード中にエラーが発生しました。もう一度お試しください。", - "uk": "Помилка завантаження файлів. Спробуйте ще раз." + "uk": "Помилка завантаження файлів. Спробуйте ще раз.", + "ca": "Error en carregar els fitxers. Torneu-ho a intentar." }, "FILE_SERVICE$LIST_FILES_ERROR": { "en": "Error listing files. Please try again.", @@ -2589,7 +2750,8 @@ "fr": "Erreur lors de la liste des fichiers. Veuillez réessayer.", "tr": "Dosyalar listelenirken hata oluştu. Lütfen tekrar deneyin.", "ja": "ファイル一覧の取得中にエラーが発生しました。もう一度お試しください。", - "uk": "Error listing files. Please try again." + "uk": "Error listing files. Please try again.", + "ca": "Error en llistar els fitxers. Torneu-ho a intentar." }, "FILE_SERVICE$SAVE_FILE_ERROR": { "en": "Error saving file. Please try again.", @@ -2605,7 +2767,8 @@ "fr": "Erreur lors de l'enregistrement du fichier. Veuillez réessayer.", "tr": "Dosya kaydedilirken hata oluştu. Lütfen tekrar deneyin.", "ja": "ファイルの保存中にエラーが発生しました。もう一度お試しください。", - "uk": "Помилка збереження файлу. Спробуйте ще раз." + "uk": "Помилка збереження файлу. Спробуйте ще раз.", + "ca": "Error en desar el fitxer. Torneu-ho a intentar." }, "SUGGESTIONS$INCREASE_TEST_COVERAGE": { "en": "Increase test coverage", @@ -2621,7 +2784,8 @@ "ar": "زيادة تغطية الاختبار", "fr": "Augmenter la couverture des tests", "tr": "Test kapsamını artır", - "uk": "Збільшити охоплення тестами" + "uk": "Збільшити охоплення тестами", + "ca": "Augmenta la cobertura de proves" }, "SUGGESTIONS$AUTO_MERGE_PRS": { "en": "Auto-merge Dependabot PRs", @@ -2637,7 +2801,8 @@ "ar": "دمج تلقائي لـ PRs من Dependabot", "fr": "Fusion automatique des PR Dependabot", "tr": "Dependabot PR'larını otomatik birleştir", - "uk": "Автоматичне злиття PR" + "uk": "Автоматичне злиття PR", + "ca": "Fusió automàtica de les PR de Dependabot" }, "SUGGESTIONS$FIX_README": { "en": "Improve README", @@ -2653,7 +2818,8 @@ "ar": "تحسين README", "fr": "Améliorer le README", "tr": "README'yi geliştir", - "uk": "Покращити README" + "uk": "Покращити README", + "ca": "Millora el README" }, "SUGGESTIONS$CLEAN_DEPENDENCIES": { "en": "Clean up dependencies", @@ -2669,7 +2835,8 @@ "ar": "تنظيف التبعيات", "fr": "Nettoyer les dépendances", "tr": "Bağımlılıkları temizle", - "uk": "Очищення залежностей" + "uk": "Очищення залежностей", + "ca": "Neteja les dependències" }, "SETTINGS$LLM_SETTINGS": { "en": "LLM Settings", @@ -2685,7 +2852,8 @@ "ar": "إعدادات LLM", "fr": "Paramètres LLM", "tr": "LLM Ayarları", - "uk": "LLM налаштування" + "uk": "LLM налаштування", + "ca": "Configuració del LLM" }, "SETTINGS$GIT_SETTINGS": { "en": "Git Settings", @@ -2701,7 +2869,8 @@ "ar": "إعدادات Git", "fr": "Paramètres Git", "tr": "Git Ayarları", - "uk": "Git налаштування" + "uk": "Git налаштування", + "ca": "Configuració de Git" }, "SETTINGS$GIT_SETTINGS_DESCRIPTION": { "en": "Configure the username and email that OpenHands uses to commit changes.", @@ -2717,7 +2886,8 @@ "ar": "قم بتكوين اسم المستخدم والبريد الإلكتروني الذي يستخدمه OpenHands لارتكاب التغييرات.", "fr": "Configurez le nom d'utilisateur et l'email qu'OpenHands utilise pour valider les modifications.", "tr": "OpenHands'ın değişiklikleri commit etmek için kullandığı kullanıcı adını ve e-postayı yapılandırın.", - "uk": "Налаштуйте ім'я користувача та електронну пошту, які OpenHands використовує для фіксації змін." + "uk": "Налаштуйте ім'я користувача та електронну пошту, які OpenHands використовує для фіксації змін.", + "ca": "Configura el nom d'usuari i el correu electrònic que OpenHands utilitza per confirmar els canvis." }, "SETTINGS$SOUND_NOTIFICATIONS": { "en": "Sound Notifications", @@ -2733,7 +2903,8 @@ "ar": "إشعارات صوتية", "fr": "Notifications sonores", "tr": "Ses Bildirimleri", - "uk": "Звукові сповіщення" + "uk": "Звукові сповіщення", + "ca": "Notificacions de so" }, "SETTINGS$MAX_BUDGET_PER_TASK": { "en": "Maximum Budget Per Task", @@ -2749,7 +2920,8 @@ "ar": "الميزانية القصوى لكل مهمة", "fr": "Budget maximum par tâche", "tr": "Görev Başına Maksimum Bütçe", - "uk": "Максимальний бюджет на завдання" + "uk": "Максимальний бюджет на завдання", + "ca": "Pressupost màxim per tasca" }, "SETTINGS$MAX_BUDGET_PER_CONVERSATION": { "en": "Maximum Budget Per Conversation", @@ -2765,7 +2937,8 @@ "ar": "الميزانية القصوى لكل محادثة", "fr": "Budget maximum par conversation", "tr": "Konuşma Başına Maksimum Bütçe", - "uk": "Максимальний бюджет на розмову" + "uk": "Максимальний бюджет на розмову", + "ca": "Pressupost màxim per conversa" }, "SETTINGS$PROACTIVE_CONVERSATION_STARTERS": { "en": "Suggest Tasks on GitHub", @@ -2781,7 +2954,8 @@ "ar": "اقتراح المهام على GitHub", "fr": "Suggérer des tâches sur GitHub", "tr": "GitHub'da Görevler Öner", - "uk": "Запропонувати завдання на GitHub" + "uk": "Запропонувати завдання на GitHub", + "ca": "Suggereix tasques a GitHub" }, "SETTINGS$SOLVABILITY_ANALYSIS": { "en": "Enable Solvability Analysis", @@ -2797,7 +2971,8 @@ "ar": "تمكين تحليل القابلية للحل", "fr": "Activer l'analyse de solvabilité", "tr": "Çözünürlük Analizini Etkinleştir", - "uk": "Увімкнути аналіз розв'язності" + "uk": "Увімкнути аналіз розв'язності", + "ca": "Activa l'anàlisi de resolubilitat" }, "SETTINGS$SANDBOX_GROUPING_STRATEGY": { "en": "Sandbox Grouping Strategy", @@ -2813,7 +2988,8 @@ "ar": "استراتيجية تجميع صندوق الرمل", "fr": "Stratégie de regroupement sandbox", "tr": "Sandbox Gruplama Stratejisi", - "uk": "Стратегія групування пісочниці" + "uk": "Стратегія групування пісочниці", + "ca": "Estratègia d'agrupació de sandbox" }, "SETTINGS$SANDBOX_GROUPING_NO_GROUPING": { "en": "No Grouping (new sandbox per conversation)", @@ -2829,7 +3005,8 @@ "ar": "بدون تجميع (صندوق رمل جديد لكل محادثة)", "fr": "Pas de regroupement (nouveau sandbox par conversation)", "tr": "Gruplama Yok (konuşma başına yeni sandbox)", - "uk": "Без групування (нова пісочниця для кожної розмови)" + "uk": "Без групування (нова пісочниця для кожної розмови)", + "ca": "Sense agrupació (nou sandbox per conversa)" }, "SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST": { "en": "Group by Newest (add to most recent sandbox)", @@ -2845,7 +3022,8 @@ "ar": "التجميع حسب الأحدث (إضافة إلى أحدث صندوق رمل)", "fr": "Regrouper par le plus récent (ajouter au sandbox le plus récent)", "tr": "En Yeniye Göre Grupla (en yeni sandbox'a ekle)", - "uk": "Групувати за найновішим (додати до найновішої пісочниці)" + "uk": "Групувати за найновішим (додати до найновішої пісочниці)", + "ca": "Agrupa pel més nou (afegeix al sandbox més recent)" }, "SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED": { "en": "Least Recently Used (add to oldest sandbox)", @@ -2861,7 +3039,8 @@ "ar": "الأقل استخدامًا مؤخرًا (إضافة إلى أقدم صندوق رمل)", "fr": "Le moins récemment utilisé (ajouter au sandbox le plus ancien)", "tr": "En Az Kullanılan (en eski sandbox'a ekle)", - "uk": "Найменш нещодавно використана (додати до найстаршої пісочниці)" + "uk": "Найменш нещодавно використана (додати до найстаршої пісочниці)", + "ca": "Menys usat recentment (afegeix al sandbox més antic)" }, "SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS": { "en": "Fewest Conversations (add to least busy sandbox)", @@ -2877,7 +3056,8 @@ "ar": "أقل محادثات (إضافة إلى صندوق الرمل الأقل انشغالاً)", "fr": "Moins de conversations (ajouter au sandbox le moins occupé)", "tr": "En Az Konuşma (en az meşgul sandbox'a ekle)", - "uk": "Найменше розмов (додати до найменш зайнятої пісочниці)" + "uk": "Найменше розмов (додати до найменш зайнятої пісочниці)", + "ca": "Menys converses (afegeix al sandbox menys ocupat)" }, "SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY": { "en": "Add to Any (use first available sandbox)", @@ -2893,7 +3073,8 @@ "ar": "إضافة إلى أي (استخدام أول صندوق رمل متاح)", "fr": "Ajouter à n'importe lequel (utiliser le premier sandbox disponible)", "tr": "Herhangi Birine Ekle (ilk uygun sandbox'ı kullan)", - "uk": "Додати до будь-якої (використовувати першу доступну пісочницю)" + "uk": "Додати до будь-якої (використовувати першу доступну пісочницю)", + "ca": "Afegeix a qualsevol (usa el primer sandbox disponible)" }, "SETTINGS$SEARCH_API_KEY": { "en": "Search API Key (Tavily)", @@ -2909,7 +3090,8 @@ "ar": "مفتاح API للبحث (Tavily)", "fr": "Clé API de recherche (Tavily)", "tr": "Arama API Anahtarı (Tavily)", - "uk": "Ключ API пошуку (Tavily)" + "uk": "Ключ API пошуку (Tavily)", + "ca": "Clau d'API de cerca (Tavily)" }, "SETTINGS$SEARCH_API_KEY_OPTIONAL": { "en": "This field is optional. We use Tavily as our default search engine provider.", @@ -2925,7 +3107,8 @@ "ar": "هذا الحقل اختياري. نستخدم Tavily كمزود محرك البحث الافتراضي.", "fr": "Ce champ est facultatif. Nous utilisons Tavily comme fournisseur de moteur de recherche par défaut.", "tr": "Bu alan isteğe bağlıdır. Varsayılan arama motoru sağlayıcısı olarak Tavily'yi kullanıyoruz.", - "uk": "Це поле є необов'язковим. Ми використовуємо Tavily як нашого типового постачальника пошукової системи." + "uk": "Це поле є необов'язковим. Ми використовуємо Tavily як нашого типового постачальника пошукової системи.", + "ca": "Aquest camp és opcional. Fem servir Tavily com a proveïdor de motor de cerca per defecte." }, "SETTINGS$SEARCH_API_KEY_INSTRUCTIONS": { "en": "Get your API key from Tavily", @@ -2941,7 +3124,8 @@ "ar": "احصل على مفتاح API الخاص بك من Tavily", "fr": "Obtenez votre clé API de Tavily", "tr": "API anahtarınızı Tavily'den alın", - "uk": "Отримайте свій ключ API від Tavily" + "uk": "Отримайте свій ключ API від Tavily", + "ca": "Obteniu la vostra clau d'API a Tavily" }, "SETTINGS$CUSTOM_MODEL": { "en": "Custom Model", @@ -2957,7 +3141,8 @@ "ar": "نموذج مخصص", "fr": "Modèle personnalisé", "tr": "Özel Model", - "uk": "Користувацька модель" + "uk": "Користувацька модель", + "ca": "Model personalitzat" }, "GITHUB$CODE_NOT_IN_GITHUB": { "en": "Code not in GitHub?", @@ -2973,7 +3158,8 @@ "ar": "الكود غير موجود على GitHub؟", "fr": "Code non présent sur GitHub ?", "tr": "Kod GitHub'da değil mi?", - "uk": "Коду немає на GitHub?" + "uk": "Коду немає на GitHub?", + "ca": "El codi no és a GitHub?" }, "GITHUB$START_FROM_SCRATCH": { "en": "Start from scratch", @@ -2989,7 +3175,8 @@ "ar": "البدء من الصفر", "fr": "Commencer de zéro", "tr": "Sıfırdan başla", - "uk": "Почати з нуля" + "uk": "Почати з нуля", + "ca": "Comença des de zero" }, "AVATAR$ALT_TEXT": { "en": "user avatar", @@ -3005,7 +3192,8 @@ "ar": "صورة المستخدم", "fr": "Avatar utilisateur", "tr": "Kullanıcı avatarı", - "uk": "аватар користувача" + "uk": "аватар користувача", + "ca": "avatar d'usuari" }, "BRANDING$OPENHANDS": { "en": "OpenHands", @@ -3021,7 +3209,8 @@ "ar": "OpenHands", "fr": "OpenHands", "tr": "OpenHands", - "uk": "OpenHands" + "uk": "OpenHands", + "ca": "OpenHands" }, "BRANDING$OPENHANDS_LOGO": { "en": "OpenHands Logo", @@ -3037,7 +3226,8 @@ "ar": "شعار OpenHands", "fr": "Logo OpenHands", "tr": "OpenHands Logosu", - "uk": "OpenHands лого" + "uk": "OpenHands лого", + "ca": "Logotip d'OpenHands" }, "ERROR$GENERIC": { "en": "An error occurred", @@ -3053,7 +3243,8 @@ "ar": "حدث خطأ", "fr": "Une erreur s'est produite", "tr": "Bir hata oluştu", - "uk": "Сталася помилка" + "uk": "Сталася помилка", + "ca": "S'ha produït un error" }, "GITHUB$AUTH_SCOPE": { "en": "openid email profile", @@ -3069,7 +3260,8 @@ "ar": "openid email profile", "fr": "openid email profile", "tr": "openid email profile", - "uk": "openid email profile" + "uk": "openid email profile", + "ca": "openid email profile" }, "FILE_SERVICE$INVALID_FILE_PATH": { "en": "Invalid file path. Please check the file name and try again.", @@ -3085,7 +3277,8 @@ "fr": "Chemin de fichier invalide. Veuillez vérifier le nom du fichier et réessayer.", "tr": "Geçersiz dosya yolu. Lütfen dosya adını kontrol edin ve tekrar deneyin.", "ja": "ファイルパスが無効です。ファイル名を確認して、もう一度お試しください。", - "uk": "Недійсний шлях до файлу. Перевірте ім'я файлу та спробуйте ще раз." + "uk": "Недійсний шлях до файлу. Перевірте ім'я файлу та спробуйте ще раз.", + "ca": "Ruta de fitxer no vàlida. Comproveu el nom del fitxer i torneu-ho a intentar." }, "VSCODE$OPEN": { "en": "Open in VS Code", @@ -3101,7 +3294,8 @@ "ar": "فتح في VS Code", "fr": "Ouvrir dans VS Code", "tr": "VS Code'da aç", - "uk": "Відкрити у VS Code" + "uk": "Відкрити у VS Code", + "ca": "Obre a VS Code" }, "VSCODE$TITLE": { "en": "VS Code", @@ -3117,7 +3311,8 @@ "ar": "VS Code", "fr": "VS Code", "tr": "VS Code", - "uk": "VS Code" + "uk": "VS Code", + "ca": "VS Code" }, "VSCODE$LOADING": { "en": "Loading VS Code...", @@ -3133,7 +3328,8 @@ "ar": "جاري تحميل VS Code...", "fr": "Chargement de VS Code...", "tr": "VS Code yükleniyor...", - "uk": "Завантажую VS Code..." + "uk": "Завантажую VS Code...", + "ca": "Carregant VS Code..." }, "VSCODE$URL_NOT_AVAILABLE": { "en": "VS Code URL not available", @@ -3149,7 +3345,8 @@ "ar": "رابط VS Code غير متوفر", "fr": "URL VS Code non disponible", "tr": "VS Code URL'si mevcut değil", - "uk": "VS Code URL недоступний" + "uk": "VS Code URL недоступний", + "ca": "URL de VS Code no disponible" }, "VSCODE$FETCH_ERROR": { "en": "Failed to fetch VS Code URL", @@ -3165,7 +3362,8 @@ "ar": "فشل في جلب رابط VS Code", "fr": "Échec de la récupération de l'URL VS Code", "tr": "VS Code URL'si alınamadı", - "uk": "Не вдалося отримати VS Code URL" + "uk": "Не вдалося отримати VS Code URL", + "ca": "No s'ha pogut obtenir la URL de VS Code" }, "VSCODE$CROSS_ORIGIN_WARNING": { "en": "The code editor cannot be embedded due to browser security restrictions. Cross-origin cookies are being blocked.", @@ -3181,7 +3379,8 @@ "ar": "لا يمكن تضمين محرر التعليمات البرمجية بسبب قيود أمان المتصفح. يتم حظر ملفات تعريف الارتباط عبر المصدر.", "fr": "L'éditeur de code ne peut pas être intégré en raison des restrictions de sécurité du navigateur. Les cookies cross-origin sont bloqués.", "tr": "Tarayıcı güvenlik kısıtlamaları nedeniyle kod düzenleyici yerleştirilemiyor. Çapraz kaynaklı çerezler engelleniyor.", - "uk": "Редактор коду не може бути вбудований через обмеження безпеки браузера. Блокуються файли cookie з різних джерел." + "uk": "Редактор коду не може бути вбудований через обмеження безпеки браузера. Блокуються файли cookie з різних джерел.", + "ca": "L'editor de codi no es pot incrustar a causa de les restriccions de seguretat del navegador. Les galetes d'origen creuat estan bloquejades." }, "VSCODE$URL_PARSE_ERROR": { "en": "Error parsing URL", @@ -3197,7 +3396,8 @@ "ar": "خطأ في تحليل عنوان URL", "fr": "Erreur d'analyse de l'URL", "tr": "URL ayrıştırma hatası", - "uk": "Помилка аналізу URL" + "uk": "Помилка аналізу URL", + "ca": "Error en analitzar la URL" }, "VSCODE$OPEN_IN_NEW_TAB": { "en": "Open in New Tab", @@ -3213,7 +3413,8 @@ "ar": "فتح في علامة تبويب جديدة", "fr": "Ouvrir dans un nouvel onglet", "tr": "Yeni Sekmede Aç", - "uk": "Відкрити в новій вкладці" + "uk": "Відкрити в новій вкладці", + "ca": "Obre en una pestanya nova" }, "INCREASE_TEST_COVERAGE": { "en": "Increase test coverage", @@ -3229,7 +3430,8 @@ "ar": "زيادة تغطية الاختبارات", "fr": "Augmenter la couverture des tests", "tr": "Test kapsamını artır", - "uk": "Збільшення охоплення тестами" + "uk": "Збільшення охоплення тестами", + "ca": "Augmenta la cobertura de proves" }, "AUTO_MERGE_PRS": { "en": "Auto-merge PRs", @@ -3245,7 +3447,8 @@ "ar": "دمج طلبات السحب تلقائياً", "fr": "Fusionner automatiquement les PR", "tr": "PR'ları otomatik birleştir", - "uk": "Автоматично поєднувати PRs" + "uk": "Автоматично поєднувати PRs", + "ca": "Fusió automàtica de PRs" }, "FIX_README": { "en": "Fix README", @@ -3261,7 +3464,8 @@ "ar": "إصلاح README", "fr": "Corriger le README", "tr": "README'yi düzelt", - "uk": "Виправити README" + "uk": "Виправити README", + "ca": "Corregeix el README" }, "CLEAN_DEPENDENCIES": { "en": "Clean dependencies", @@ -3277,7 +3481,8 @@ "ar": "تنظيف التبعيات", "fr": "Nettoyer les dépendances", "tr": "Bağımlılıkları temizle", - "uk": "Почистити залкжності" + "uk": "Почистити залкжності", + "ca": "Neteja les dependències" }, "CONFIGURATION$OPENHANDS_WORKSPACE_DIRECTORY_INPUT_LABEL": { "en": "OpenHands Workspace directory", @@ -3293,7 +3498,8 @@ "fr": "Répertoire de l'espace de travail OpenHands", "tr": "OpenHands çalışma alanı dizini", "ja": "OpenHands ワークスペースディレクトリ", - "uk": "OpenHands Каталог робочих просторів" + "uk": "OpenHands Каталог робочих просторів", + "ca": "Directori de l'espai de treball d'OpenHands" }, "LLM$PROVIDER": { "en": "LLM Provider", @@ -3309,7 +3515,8 @@ "fr": "Fournisseur LLM", "tr": "LLM Sağlayıcı", "de": "LLM-Anbieter", - "uk": "LLM Постачальник" + "uk": "LLM Постачальник", + "ca": "Proveïdor de LLM" }, "LLM$SELECT_PROVIDER_PLACEHOLDER": { "en": "Select a provider", @@ -3325,7 +3532,8 @@ "fr": "Sélectionner un fournisseur", "tr": "Bir sağlayıcı seçin", "de": "Anbieter auswählen", - "uk": "Виберіть постачальника" + "uk": "Виберіть постачальника", + "ca": "Selecciona un proveïdor" }, "API$KEY": { "en": "API Key", @@ -3341,7 +3549,8 @@ "fr": "Clé API", "tr": "API Anahtarı", "de": "API-Schlüssel", - "uk": "API ключ" + "uk": "API ключ", + "ca": "Clau d'API" }, "API$DONT_KNOW_KEY": { "en": "Don't know your API key?", @@ -3357,7 +3566,8 @@ "fr": "Vous ne connaissez pas votre clé API ?", "tr": "API anahtarınızı bilmiyor musunuz?", "de": "API-Schlüssel unbekannt?", - "uk": "Не знаєте свого API-ключа?" + "uk": "Не знаєте свого API-ключа?", + "ca": "No coneixeu la vostra clau d'API?" }, "BUTTON$SAVE": { "en": "Save", @@ -3373,7 +3583,8 @@ "fr": "Enregistrer", "tr": "Kaydet", "de": "Speichern", - "uk": "Зберегти" + "uk": "Зберегти", + "ca": "Desa" }, "BUTTON$CLOSE": { "en": "Close", @@ -3389,7 +3600,8 @@ "fr": "Fermer", "tr": "Kapat", "de": "Schließen", - "uk": "Закрити" + "uk": "Закрити", + "ca": "Tanca" }, "MODAL$CONFIRM_RESET_TITLE": { "en": "Are you sure?", @@ -3405,7 +3617,8 @@ "fr": "Êtes-vous sûr ?", "tr": "Emin misiniz?", "de": "Sind Sie sicher?", - "uk": "Are you sure?" + "uk": "Are you sure?", + "ca": "Esteu segur?" }, "MODAL$CONFIRM_RESET_MESSAGE": { "en": "All information will be deleted", @@ -3421,7 +3634,8 @@ "fr": "Toutes les informations seront supprimées", "tr": "Tüm bilgiler silinecek", "de": "Möchten Sie alle Einstellungen zurücksetzen?", - "uk": "Всю інформацію буде видалено" + "uk": "Всю інформацію буде видалено", + "ca": "Tota la informació s'eliminarà" }, "MODAL$END_SESSION_TITLE": { "en": "End Session", @@ -3437,7 +3651,8 @@ "fr": "Terminer la session", "tr": "Oturumu sonlandır", "de": "Sitzung beenden", - "uk": "Закінчити сеанс" + "uk": "Закінчити сеанс", + "ca": "Finalitza la sessió" }, "MODAL$END_SESSION_MESSAGE": { "en": "Changing workspace settings will end the current session", @@ -3453,7 +3668,8 @@ "fr": "La modification des paramètres de l'espace de travail mettra fin à la session en cours", "tr": "Çalışma alanı ayarlarını değiştirmek mevcut oturumu sonlandıracak", "de": "Möchten Sie die aktuelle Sitzung beenden?", - "uk": "Зміна налаштувань робочого простору завершить поточний сеанс" + "uk": "Зміна налаштувань робочого простору завершить поточний сеанс", + "ca": "Canviar la configuració de l'espai de treball finalitzarà la sessió actual" }, "BUTTON$END_SESSION": { "en": "End Session", @@ -3469,7 +3685,8 @@ "fr": "Terminer la session", "tr": "Oturumu sonlandır", "de": "Sitzung beenden", - "uk": "Закінчити сеанс" + "uk": "Закінчити сеанс", + "ca": "Finalitza la sessió" }, "BUTTON$LAUNCH": { "en": "Launch", @@ -3485,7 +3702,8 @@ "fr": "Lancer", "tr": "Başlat", "de": "Starten", - "uk": "Запустити" + "uk": "Запустити", + "ca": "Llança" }, "BUTTON$CANCEL": { "en": "Cancel", @@ -3501,7 +3719,8 @@ "fr": "Annuler", "tr": "İptal", "de": "Abbrechen", - "uk": "Скасувати" + "uk": "Скасувати", + "ca": "Cancel·la" }, "BUTTON$ADD": { "en": "Add", @@ -3517,7 +3736,8 @@ "fr": "Ajouter", "tr": "Ekle", "de": "Hinzufügen", - "uk": "Додати" + "uk": "Додати", + "ca": "Afegeix" }, "EXIT_PROJECT$CONFIRM": { "en": "Exit Project", @@ -3533,7 +3753,8 @@ "fr": "Quitter le projet", "tr": "Projeden çık", "de": "Projekt verlassen", - "uk": "Exit Project" + "uk": "Exit Project", + "ca": "Surt del projecte" }, "EXIT_PROJECT$TITLE": { "en": "Are you sure you want to exit this project?", @@ -3549,7 +3770,8 @@ "fr": "Êtes-vous sûr de vouloir quitter ce projet ?", "tr": "Bu projeden çıkmak istediğinizden emin misiniz?", "de": "Sind Sie sicher, dass Sie dieses Projekt verlassen möchten?", - "uk": "Ви впевнені, що хочете вийти з цього проєкту?" + "uk": "Ви впевнені, що хочете вийти з цього проєкту?", + "ca": "Esteu segur que voleu sortir d'aquest projecte?" }, "LANGUAGE$LABEL": { "en": "Language", @@ -3565,7 +3787,8 @@ "fr": "Langue", "tr": "Dil", "de": "Sprache", - "uk": "Мова" + "uk": "Мова", + "ca": "Idioma" }, "GITHUB$TOKEN_LABEL": { "en": "GitHub Token", @@ -3581,7 +3804,8 @@ "fr": "Jeton GitHub", "tr": "GitHub Jetonu", "de": "GitHub-Token", - "uk": "GitHub Токен" + "uk": "GitHub Токен", + "ca": "Token de GitHub" }, "GITHUB$HOST_LABEL": { "en": "GitHub Host (optional)", @@ -3597,7 +3821,8 @@ "fr": "Hôte GitHub (optionnel)", "tr": "GitHub Sunucusu (isteğe bağlı)", "de": "GitHub-Host (optional)", - "uk": "Хост GitHub (необов'язково)" + "uk": "Хост GitHub (необов'язково)", + "ca": "Servidor de GitHub (opcional)" }, "GITHUB$TOKEN_OPTIONAL": { "en": "GitHub Token (Optional)", @@ -3613,7 +3838,8 @@ "fr": "Jeton GitHub (facultatif)", "tr": "GitHub Jetonu (İsteğe bağlı)", "de": "(Optional)", - "uk": "Токен GitHub (необов'язково)" + "uk": "Токен GitHub (необов'язково)", + "ca": "Token de GitHub (opcional)" }, "GITHUB$GET_TOKEN": { "en": "Get your token", @@ -3629,7 +3855,8 @@ "fr": "Obtenir votre jeton", "tr": "Jetonunuzu alın", "de": "Token abrufen", - "uk": "Отримайте свій токен" + "uk": "Отримайте свій токен", + "ca": "Obteniu el vostre token" }, "GITHUB$TOKEN_HELP_TEXT": { "en": "Get your <0>GitHub token or <1>click here for instructions", @@ -3645,7 +3872,8 @@ "fr": "Obtenez votre <0>jeton GitHub ou <1>cliquez ici pour les instructions", "tr": "<0>GitHub jetonu alın veya <1>talimatlar için buraya tıklayın", "de": "Holen Sie sich Ihren <0>GitHub-Token oder <1>klicken Sie hier für Anweisungen", - "uk": "Отримайте свій <0>токен GitHub або <1>натисніть тут, щоб отримати інструкції" + "uk": "Отримайте свій <0>токен GitHub або <1>натисніть тут, щоб отримати інструкції", + "ca": "Obteniu el vostre <0>token de GitHub o <1>feu clic aquí per obtenir instruccions" }, "GITHUB$TOKEN_LINK_TEXT": { "en": "GitHub token", @@ -3661,7 +3889,8 @@ "fr": "jeton GitHub", "tr": "GitHub jetonu", "de": "GitHub-Token", - "uk": "Токен GitHub" + "uk": "Токен GitHub", + "ca": "token de GitHub" }, "GITHUB$INSTRUCTIONS_LINK_TEXT": { "en": "click here for instructions", @@ -3677,7 +3906,8 @@ "fr": "cliquez ici pour les instructions", "tr": "talimatlar için buraya tıklayın", "de": "klicken Sie hier für Anweisungen", - "uk": "натисніть тут, щоб отримати інструкції" + "uk": "натисніть тут, щоб отримати інструкції", + "ca": "feu clic aquí per obtenir instruccions" }, "COMMON$HERE": { "en": "here", @@ -3693,7 +3923,8 @@ "fr": "ici", "tr": "buradan", "de": "Hier", - "uk": "тут" + "uk": "тут", + "ca": "aquí" }, "GITHUB$TOKEN_INVALID": { "en": "Invalid GitHub token", @@ -3709,7 +3940,8 @@ "fr": "Jeton GitHub invalide", "tr": "Geçersiz GitHub jetonu", "de": "GitHub-Token ungültig", - "uk": "Недійсний токен GitHub" + "uk": "Недійсний токен GitHub", + "ca": "Token de GitHub no vàlid" }, "BUTTON$DISCONNECT": { "en": "Disconnect", @@ -3725,7 +3957,8 @@ "fr": "Déconnecter", "tr": "Bağlantıyı kes", "de": "Verbindung trennen", - "uk": "Відключитися" + "uk": "Відключитися", + "ca": "Desconnecta" }, "GITHUB$CONFIGURE_REPOS": { "en": "Configure Github Repositories", @@ -3741,7 +3974,8 @@ "fr": "Configurer les dépôts GitHub", "tr": "GitHub depolarını yapılandır", "de": "GitHub-Repositories konfigurieren", - "uk": "Налаштування репозиторіїв Github" + "uk": "Налаштування репозиторіїв Github", + "ca": "Configura els repositoris de GitHub" }, "SLACK$INSTALL_APP": { "en": "Install OpenHands Slack App", @@ -3757,7 +3991,8 @@ "fr": "Installer l'application Slack OpenHands", "tr": "OpenHands Slack uygulamasını yükle", "de": "OpenHands Slack-App installieren", - "uk": "Встановити додаток OpenHands Slack" + "uk": "Встановити додаток OpenHands Slack", + "ca": "Instal·la l'aplicació Slack d'OpenHands" }, "COMMON$CLICK_FOR_INSTRUCTIONS": { "en": "Click here for instructions", @@ -3773,7 +4008,8 @@ "fr": "Cliquez ici pour les instructions", "tr": "Talimatlar için buraya tıklayın", "de": "Für Anweisungen klicken", - "uk": "Натисніть тут, щоб отримати інструкції" + "uk": "Натисніть тут, щоб отримати інструкції", + "ca": "Feu clic aquí per obtenir instruccions" }, "LLM$SELECT_MODEL_PLACEHOLDER": { "en": "Select a model", @@ -3789,7 +4025,8 @@ "fr": "Sélectionner un modèle", "tr": "Bir model seçin", "de": "Modell auswählen", - "uk": "Виберіть модель" + "uk": "Виберіть модель", + "ca": "Selecciona un model" }, "LLM$MODEL": { "en": "LLM Model", @@ -3805,7 +4042,8 @@ "fr": "Modèle LLM", "tr": "LLM Modeli", "de": "LLM-Modell", - "uk": "LLM модель" + "uk": "LLM модель", + "ca": "Model de LLM" }, "CONFIGURATION$OPENHANDS_WORKSPACE_DIRECTORY_INPUT_PLACEHOLDER": { "en": "Default: ./workspace", @@ -3821,7 +4059,8 @@ "es": "Predeterminado: ./workspace", "ja": "デフォルト: ./workspace", "tr": "Çalışma alanı dizinini girin", - "uk": "За замовчуванням: ./workspace" + "uk": "За замовчуванням: ./workspace", + "ca": "Per defecte: ./workspace" }, "CONFIGURATION$MODAL_TITLE": { "en": "Configuration", @@ -3837,7 +4076,8 @@ "fr": "Configuration", "tr": "Konfigürasyon", "ja": "設定", - "uk": "Конфігурація" + "uk": "Конфігурація", + "ca": "Configuració" }, "CONFIGURATION$MODEL_SELECT_LABEL": { "en": "Model", @@ -3853,7 +4093,8 @@ "fr": "Modèle", "tr": "Model", "ja": "モデル", - "uk": "Модель" + "uk": "Модель", + "ca": "Model" }, "CONFIGURATION$MODEL_SELECT_PLACEHOLDER": { "en": "Select a model", @@ -3869,7 +4110,8 @@ "fr": "Sélectionner un modèle", "tr": "Model Seç", "ja": "モデルを選択", - "uk": "Виберіть модель" + "uk": "Виберіть модель", + "ca": "Selecciona un model" }, "CONFIGURATION$AGENT_SELECT_LABEL": { "en": "Agent", @@ -3885,7 +4127,8 @@ "fr": "Agent", "tr": "Ajan", "ja": "エージェント", - "uk": "Агент" + "uk": "Агент", + "ca": "Agent" }, "CONFIGURATION$AGENT_SELECT_PLACEHOLDER": { "en": "Select an agent", @@ -3901,7 +4144,8 @@ "fr": "Sélectionner un agent", "tr": "Ajan Seç", "ja": "エージェントを選択", - "uk": "Виберіть агента" + "uk": "Виберіть агента", + "ca": "Selecciona un agent" }, "CONFIGURATION$LANGUAGE_SELECT_LABEL": { "en": "Language", @@ -3917,7 +4161,8 @@ "es": "Idioma", "ja": "言語", "tr": "Dil", - "uk": "Мова" + "uk": "Мова", + "ca": "Idioma" }, "CONFIGURATION$LANGUAGE_SELECT_PLACEHOLDER": { "en": "Select a language", @@ -3933,7 +4178,8 @@ "fr": "Sélectionner une langue", "tr": "Dil Seç", "ja": "言語を選択", - "uk": "Виберіть мову" + "uk": "Виберіть мову", + "ca": "Selecciona un idioma" }, "CONFIGURATION$SECURITY_SELECT_LABEL": { "en": "Security analyzer", @@ -3949,7 +4195,8 @@ "fr": "Analyseur de sécurité", "tr": "Güvenlik analizörü", "ja": "セキュリティアナライザー", - "uk": "Аналізатор безпеки" + "uk": "Аналізатор безпеки", + "ca": "Analitzador de seguretat" }, "CONFIGURATION$SECURITY_SELECT_PLACEHOLDER": { "en": "Select a security analyzer (optional)", @@ -3965,7 +4212,8 @@ "fr": "Sélectionnez un analyseur de sécurité (facultatif)", "tr": "Bir güvenlik analizörü seçin (isteğe bağlı)", "ja": "セキュリティアナライザーを選択(オプション)", - "uk": "Виберіть аналізатор безпеки (необов'язково)" + "uk": "Виберіть аналізатор безпеки (необов'язково)", + "ca": "Selecciona un analitzador de seguretat (opcional)" }, "CONFIGURATION$MODAL_CLOSE_BUTTON_LABEL": { "en": "Close", @@ -3981,7 +4229,8 @@ "fr": "Fermer", "tr": "Kapat", "ja": "閉じる", - "uk": "Закрити" + "uk": "Закрити", + "ca": "Tanca" }, "CONFIGURATION$MODAL_SAVE_BUTTON_LABEL": { "en": "Save", @@ -3997,7 +4246,8 @@ "es": "Guardar", "ja": "保存", "tr": "Kaydet", - "uk": "Зберегти" + "uk": "Зберегти", + "ca": "Desa" }, "CONFIGURATION$MODAL_RESET_BUTTON_LABEL": { "en": "Reset to defaults", @@ -4013,7 +4263,8 @@ "fr": "Réinitialiser aux valeurs par défaut", "tr": "Varsayılanlara Sıfırla", "ja": "デフォルトにリセット", - "uk": "Скинути до налаштувань за замовчуванням" + "uk": "Скинути до налаштувань за замовчуванням", + "ca": "Restableix els valors per defecte" }, "STATUS$CONNECTED_TO_SERVER": { "en": "Connected to server", @@ -4029,7 +4280,8 @@ "ar": "متصل بالخادم", "fr": "Connecté au serveur", "tr": "Sunucuya bağlandı", - "uk": "Підключено до сервера" + "uk": "Підключено до сервера", + "ca": "Connectat al servidor" }, "PROJECT$NEW_PROJECT": { "en": "New Project", @@ -4045,7 +4297,8 @@ "ar": "مشروع جديد", "fr": "Nouveau projet", "tr": "Yeni Proje", - "uk": "Новий проект" + "uk": "Новий проект", + "ca": "Nou projecte" }, "BROWSER$SCREENSHOT": { "en": "Browser Screenshot", @@ -4061,7 +4314,8 @@ "it": "Screenshot del browser", "pt": "Captura de tela do navegador", "es": "Captura de pantalla del navegador", - "uk": "Знімок екрана браузера" + "uk": "Знімок екрана браузера", + "ca": "Captura de pantalla del navegador" }, "TIME$MINUTES_AGO": { "en": "minutes ago", @@ -4077,7 +4331,8 @@ "ar": "دقائق مضت", "fr": "minutes", "tr": "dakika önce", - "uk": "хвилин тому" + "uk": "хвилин тому", + "ca": "minuts enrere" }, "TIME$HOURS_AGO": { "en": "hours ago", @@ -4093,7 +4348,8 @@ "ar": "ساعات مضت", "fr": "heures", "tr": "saat önce", - "uk": "годин тому" + "uk": "годин тому", + "ca": "hores enrere" }, "TIME$DAYS_AGO": { "en": "days ago", @@ -4109,7 +4365,8 @@ "ar": "أيام مضت", "fr": "jours", "tr": "gün önce", - "uk": "днів тому" + "uk": "днів тому", + "ca": "dies enrere" }, "SETTINGS_FORM$RUNTIME_SIZE_LABEL": { "en": "Runtime Settings", @@ -4125,7 +4382,8 @@ "fr": "Paramètres d'exécution", "tr": "Çalışma Zamanı Ayarları", "ja": "ランタイムサイズ", - "uk": "Налаштування середовища виконання" + "uk": "Налаштування середовища виконання", + "ca": "Configuració de l'entorn d'execució" }, "CONFIGURATION$SETTINGS_NEED_UPDATE_MESSAGE": { "en": "We've changed some settings in the latest update. Take a minute to review.", @@ -4141,7 +4399,8 @@ "fr": "Nous avons modifié certains paramètres dans la dernière mise à jour. Prenez un moment pour les examiner.", "tr": "Son güncellemede bazı ayarları değiştirdik. Gözden geçirmek için bir dakikanızı ayırın.", "ja": "最新のアップデートで一部の設定を変更しました。確認のためにお時間をいただけますか。", - "uk": "Ми змінили деякі налаштування в останньому оновленні. Приділіть хвилинку, щоб переглянути їх." + "uk": "Ми змінили деякі налаштування в останньому оновленні. Приділіть хвилинку, щоб переглянути їх.", + "ca": "Hem canviat alguns paràmetres en l'última actualització. Preneu un moment per revisar-los." }, "CONFIGURATION$AGENT_LOADING": { "en": "Please wait while the agent loads. This may take a few minutes...", @@ -4157,7 +4416,8 @@ "fr": "Veuillez patienter pendant le chargement de l'agent. Cela peut prendre quelques minutes...", "tr": "Lütfen ajan yüklenirken bekleyin. Bu birkaç dakika sürebilir...", "ja": "エージェントの読み込み中です。数分かかる場合があります...", - "uk": "Будь ласка, зачекайте, поки завантажиться агент. Це може тривати кілька хвилин...." + "uk": "Будь ласка, зачекайте, поки завантажиться агент. Це може тривати кілька хвилин....", + "ca": "Espereu mentre es carrega l'agent. Això pot trigar uns minuts..." }, "CONFIGURATION$AGENT_RUNNING": { "en": "Please stop the agent before editing these settings.", @@ -4173,7 +4433,8 @@ "fr": "Veuillez arrêter l'agent avant de modifier ces paramètres.", "tr": "Bu ayarları düzenlemeden önce lütfen ajanı durdurun.", "ja": "これらの設定を編集する前にエージェントを停止してください。", - "uk": "Будь ласка, зупиніть агента, перш ніж редагувати ці налаштування." + "uk": "Будь ласка, зупиніть агента, перш ніж редагувати ці налаштування.", + "ca": "Atureu l'agent abans d'editar aquests paràmetres." }, "CONFIGURATION$ERROR_FETCH_MODELS": { "en": "Failed to fetch models and agents", @@ -4189,7 +4450,8 @@ "tr": "Modeller ve ajanlar getirilemedi", "no": "Kunne ikke hente modeller og agenter", "ja": "モデルとエージェントの取得に失敗しました", - "uk": "Не вдалося отримати моделі та агентів" + "uk": "Не вдалося отримати моделі та агентів", + "ca": "No s'han pogut obtenir els models i agents" }, "CONFIGURATION$SETTINGS_NOT_FOUND": { "en": "Settings not found. Please check your API key", @@ -4205,7 +4467,8 @@ "fr": "Paramètres non trouvés. Veuillez vérifier votre clé API", "it": "Impostazioni non trovate. Controlla la tua chiave API", "pt": "Configurações não encontradas. Por favor, verifique sua chave API", - "tr": "Ayarlar bulunamadı. Lütfen API anahtarınızı kontrol edin" + "tr": "Ayarlar bulunamadı. Lütfen API anahtarınızı kontrol edin", + "ca": "Configuració no trobada. Comproveu la vostra clau d'API" }, "CONNECT_TO_GITHUB_BY_TOKEN_MODAL$TERMS_OF_SERVICE": { "en": "terms of service", @@ -4221,7 +4484,8 @@ "fr": "conditions d'utilisation", "it": "termini di servizio", "pt": "termos de serviço", - "tr": "hizmet şartları" + "tr": "hizmet şartları", + "ca": "condicions del servei" }, "SESSION$SERVER_CONNECTED_MESSAGE": { "en": "Connected to server", @@ -4237,7 +4501,8 @@ "tr": "Sunucuya bağlandı", "no": "Koblet til server", "uk": "Підключено до сервера", - "ja": "サーバーに接続しました" + "ja": "サーバーに接続しました", + "ca": "Connectat al servidor" }, "SESSION$SESSION_HANDLING_ERROR_MESSAGE": { "en": "Error handling message", @@ -4253,7 +4518,8 @@ "tr": "Mesaj işlenirken hata oluştu", "no": "Feil ved behandling av melding", "ja": "メッセージの処理中にエラーが発生しました", - "uk": "Помилка обробки повідомлення" + "uk": "Помилка обробки повідомлення", + "ca": "Error en gestionar el missatge" }, "SESSION$SESSION_CONNECTION_ERROR_MESSAGE": { "en": "Error connecting to session", @@ -4269,7 +4535,8 @@ "tr": "Oturuma bağlanırken hata oluştu", "no": "Feil ved tilkobling til økt", "ja": "セッションへの接続中にエラーが発生しました", - "uk": "Помилка підключення до сеансу" + "uk": "Помилка підключення до сеансу", + "ca": "Error en connectar a la sessió" }, "SESSION$SOCKET_NOT_INITIALIZED_ERROR_MESSAGE": { "en": "Socket not initialized", @@ -4285,7 +4552,8 @@ "tr": "Soket başlatılmadı", "no": "Socket ikke initialisert", "ja": "ソケットが初期化されていません", - "uk": "Сокет не ініціалізовано" + "uk": "Сокет не ініціалізовано", + "ca": "El socket no s'ha inicialitzat" }, "SESSION$TIMEOUT_MESSAGE": { "en": "Session Timeout!", @@ -4301,7 +4569,8 @@ "tr": "Oturum Zaman Aşımı!", "no": "Økten har tidsavbrutt!", "ja": "セッションタイムアウト!", - "uk": "Час сеансу минув!" + "uk": "Час сеансу минув!", + "ca": "La sessió ha caducat!" }, "EXPLORER$UPLOAD_ERROR_MESSAGE": { "en": "Error uploading file", @@ -4317,7 +4586,8 @@ "tr": "Dosya yüklenirken hata oluştu", "no": "Feil ved opplasting av fil", "ja": "ファイルのアップロード中にエラーが発生しました", - "uk": "Помилка завантаження файлу" + "uk": "Помилка завантаження файлу", + "ca": "Error en carregar el fitxer" }, "EXPLORER$LABEL_DROP_FILES": { "en": "Drop files here", @@ -4333,7 +4603,8 @@ "ar": "أسقط الملفات هنا", "tr": "Dosyaları buraya bırakın", "ja": "ここにファイルをドロップ", - "uk": "Перетягніть файли сюди" + "uk": "Перетягніть файли сюди", + "ca": "Deixeu els fitxers aquí" }, "EXPLORER$UPLOAD_SUCCESS_MESSAGE": { "en": "Successfully uploaded {{count}} file(s)", @@ -4349,7 +4620,8 @@ "tr": "{{count}} dosya başarıyla yüklendi", "no": "Lastet opp {{count}} fil(er) vellykket", "ja": "{{count}}個のファイルが正常にアップロードされました", - "uk": "Успішно завантажено файлів: {{count}}" + "uk": "Успішно завантажено файлів: {{count}}", + "ca": "S'han carregat {{count}} fitxer(s) correctament" }, "EXPLORER$NO_FILES_UPLOADED_MESSAGE": { "en": "No files were uploaded", @@ -4365,7 +4637,8 @@ "tr": "Hiçbir dosya yüklenmedi", "no": "Ingen filer ble lastet opp", "ja": "アップロードされたファイルはありません", - "uk": "Жодних файлів не завантажено" + "uk": "Жодних файлів не завантажено", + "ca": "No s'ha carregat cap fitxer" }, "EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE": { "en": "{{count}} file(s) were skipped during upload", @@ -4381,7 +4654,8 @@ "tr": "Yükleme sırasında {{count}} dosya atlandı", "no": "{{count}} fil(er) ble hoppet over under opplasting", "ja": "アップロード中に{{count}}個のファイルがスキップされました", - "uk": "{{count}} файлів було пропущено під час завантаження" + "uk": "{{count}} файлів було пропущено під час завантаження", + "ca": "{{count}} fitxer(s) s'han omès durant la càrrega" }, "EXPLORER$UPLOAD_UNEXPECTED_RESPONSE_MESSAGE": { "en": "Unexpected response structure from server", @@ -4397,7 +4671,8 @@ "tr": "Sunucudan beklenmeyen yanıt yapısı", "no": "Uventet responsstruktur fra serveren", "ja": "サーバーから予期しない応答構造が返されました", - "uk": "Неочікувана структура відповіді від сервера" + "uk": "Неочікувана структура відповіді від сервера", + "ca": "Estructura de resposta inesperada del servidor" }, "EXPLORER$VSCODE_SWITCHING_MESSAGE": { "en": "Switching to VS Code in 3 seconds...\nImportant: Please inform the agent of any changes you make in VS Code. To avoid conflicts, wait for the assistant to complete its work before making your own changes.", @@ -4413,7 +4688,8 @@ "pt": "Mudando para o VS Code em 3 segundos...\nImportante: informe o agente sobre quaisquer alterações feitas no VS Code. Para evitar conflitos, aguarde até que o assistente conclua seu trabalho antes de fazer suas próprias alterações.", "es": "Cambiando a VS Code en 3 segundos...\nImportante: informe al agente de cualquier cambio realizado en VS Code. Para evitar conflictos, espere a que el asistente termine su trabajo antes de hacer sus propios cambios.", "tr": "3 saniye içinde VS Code'a geçiliyor...\nÖnemli: VS Code’da yaptığınız değişiklikleri aracıya bildirin. Çakışmaları önlemek için kendi değişikliklerinizi yapmadan önce asistanın işini bitirmesini bekleyin.", - "uk": "Перехід до VS Code через 3 секунди...\nВажливо: повідомте агента про будь-які зміни, які ви робите у VS Code. Щоб уникнути конфліктів, дочекайтеся завершення роботи помічника перед власними змінами." + "uk": "Перехід до VS Code через 3 секунди...\nВажливо: повідомте агента про будь-які зміни, які ви робите у VS Code. Щоб уникнути конфліктів, дочекайтеся завершення роботи помічника перед власними змінами.", + "ca": "Canviant a VS Code en 3 segons...\nImportant: Informeu l'agent de qualsevol canvi que feu a VS Code. Per evitar conflictes, espereu que l'assistent completi el seu treball abans de fer els vostres propis canvis." }, "EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE": { "en": "Error switching to VS Code: {{error}}", @@ -4429,7 +4705,8 @@ "pt": "Erro ao mudar para o VS Code: {{error}}", "es": "Error al cambiar a VS Code: {{error}}", "tr": "VS Code'a geçişte hata: {{error}}", - "uk": "Помилка перемикання на VS Code: {{error}}" + "uk": "Помилка перемикання на VS Code: {{error}}", + "ca": "Error en canviar a VS Code: {{error}}" }, "LOAD_SESSION$MODAL_TITLE": { "en": "Return to existing session?", @@ -4445,7 +4722,8 @@ "tr": "Mevcut oturuma dönmek ister misiniz?", "ja": "既存のセッションに戻りますか?", "no": "Gå tilbake til eksisterende økt?", - "uk": "Повернутися до існуючого сеансу?" + "uk": "Повернутися до існуючого сеансу?", + "ca": "Tornar a la sessió existent?" }, "LOAD_SESSION$MODAL_CONTENT": { "en": "You seem to have an ongoing session. Would you like to pick up where you left off, or start fresh?", @@ -4461,7 +4739,8 @@ "zh-TW": "您似乎有一個未完成的任務。您想從上次離開的地方繼續還是重新開始?", "ja": "進行中のセッションがあるようです。中断した箇所から再開しますか、それとも新しく始めますか?", "no": "Det ser ut som du har en pågående økt. Vil du fortsette der du slapp, eller starte på nytt?", - "uk": "Схоже, у вас триває сеанс. Ви хочете продовжити з того місця, де зупинилися, чи почати спочатку?" + "uk": "Схоже, у вас триває сеанс. Ви хочете продовжити з того місця, де зупинилися, чи почати спочатку?", + "ca": "Sembla que teniu una sessió en curs. Voleu reprendre-la o començar de nou?" }, "LOAD_SESSION$RESUME_SESSION_MODAL_ACTION_LABEL": { "en": "Resume Session", @@ -4477,7 +4756,8 @@ "ar": "استئناف الجلسة", "tr": "Oturumu Devam Ettir", "ja": "セッションを再開", - "uk": "Відновити сеанс" + "uk": "Відновити сеанс", + "ca": "Reprèn la sessió" }, "LOAD_SESSION$START_NEW_SESSION_MODAL_ACTION_LABEL": { "en": "Start New Session", @@ -4493,7 +4773,8 @@ "tr": "Yeni Oturum Başlat", "ja": "新しいセッションを開始", "no": "Start ny økt", - "uk": "Почати новий сеанс" + "uk": "Почати новий сеанс", + "ca": "Inicia una sessió nova" }, "FEEDBACK$MODAL_TITLE": { "en": "Share feedback", @@ -4509,7 +4790,8 @@ "tr": "Geri bildirim paylaş", "ja": "フィードバックを共有", "no": "Del tilbakemelding", - "uk": "Поділитися відгуком" + "uk": "Поділитися відгуком", + "ca": "Comparteix comentaris" }, "FEEDBACK$MODAL_CONTENT": { "en": "To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data.", @@ -4525,7 +4807,8 @@ "ar": "لمساعدتنا على التحسين، نقوم بجمع التعليقات من تفاعلاتك لتحسين مطالباتنا. من خلال إرسال هذا النموذج، فإنك توافق على جمعنا لهذه البيانات.", "tr": "Kendimizi geliştirmemize yardımcı olmak için, etkileşimlerinizden geri bildirim toplayarak ipuçlarımızı iyileştiriyoruz. Bu formu göndererek, bu verileri toplamamıza izin vermiş olursunuz.", "ja": "サービス改善のため、プロンプトの改善に向けてユーザーの操作からフィードバックを収集しています。このフォームを送信することで、データ収集に同意したことになります。", - "uk": "Щоб покращити роботу, ми збираємо відгуки про вашу взаємодію з нами, щоб покращити наші промпти. Надсилаючи цю форму, ви погоджуєтеся на збір цих даних." + "uk": "Щоб покращити роботу, ми збираємо відгуки про вашу взаємодію з нами, щоб покращити наші промпти. Надсилаючи цю форму, ви погоджуєтеся на збір цих даних.", + "ca": "Per ajudar-nos a millorar, recopilem comentaris de les vostres interaccions per millorar els nostres missatges. En enviar aquest formulari, accepteu que recopilem aquestes dades." }, "FEEDBACK$EMAIL_LABEL": { "en": "Your email", @@ -4541,7 +4824,8 @@ "tr": "E-posta adresiniz", "ja": "メールアドレス", "no": "Din e-post", - "uk": "Ваша електронна адреса" + "uk": "Ваша електронна адреса", + "ca": "El vostre correu electrònic" }, "FEEDBACK$CONTRIBUTE_LABEL": { "en": "Contribute to public dataset", @@ -4557,7 +4841,8 @@ "ar": "المساهمة في مجموعة البيانات العامة", "tr": "Genel veri setine katkıda bulun", "ja": "公開データセットに貢献", - "uk": "Зробити внесок у публічний датасет" + "uk": "Зробити внесок у публічний датасет", + "ca": "Contribueix al conjunt de dades públic" }, "FEEDBACK$SHARE_LABEL": { "en": "Share", @@ -4573,7 +4858,8 @@ "tr": "Paylaş", "ja": "共有", "no": "Del", - "uk": "Поділитися" + "uk": "Поділитися", + "ca": "Comparteix" }, "FEEDBACK$CANCEL_LABEL": { "en": "Cancel", @@ -4589,7 +4875,8 @@ "ar": "إلغاء", "tr": "İptal", "ja": "キャンセル", - "uk": "Скасувати" + "uk": "Скасувати", + "ca": "Cancel·la" }, "FEEDBACK$EMAIL_PLACEHOLDER": { "en": "Enter your email address", @@ -4605,7 +4892,8 @@ "pt": "Digite seu endereço de e-mail", "tr": "E-posta adresinizi girin", "ja": "メールアドレスを入力してください", - "uk": "Введіть свою адресу електронної пошти" + "uk": "Введіть свою адресу електронної пошти", + "ca": "Introduïu la vostra adreça de correu electrònic" }, "FEEDBACK$PASSWORD_COPIED_MESSAGE": { "en": "Password copied to clipboard.", @@ -4621,7 +4909,8 @@ "pt": "Senha copiada para a área de transferência.", "tr": "Parola panoya kopyalandı.", "ja": "パスワードがクリップボードにコピーされました。", - "uk": "Пароль скопійовано в буфер обміну." + "uk": "Пароль скопійовано в буфер обміну.", + "ca": "Contrasenya copiada al porta-retalls." }, "FEEDBACK$GO_TO_FEEDBACK": { "en": "Go to shared feedback", @@ -4637,7 +4926,8 @@ "pt": "Ir para feedback compartilhado", "tr": "Paylaşılan geri bildirimlere git", "ja": "共有されたフィードバックへ移動", - "uk": "Перейти до відгуку" + "uk": "Перейти до відгуку", + "ca": "Vés als comentaris compartits" }, "FEEDBACK$PASSWORD": { "en": "Password:", @@ -4653,7 +4943,8 @@ "pt": "Senha:", "tr": "Parola:", "ja": "パスワード:", - "uk": "Пароль:" + "uk": "Пароль:", + "ca": "Contrasenya:" }, "FEEDBACK$INVALID_EMAIL_FORMAT": { "en": "Invalid email format", @@ -4669,7 +4960,8 @@ "pt": "Formato de e-mail inválido", "tr": "Geçersiz e-posta biçimi", "ja": "無効なメールアドレス形式", - "uk": "Недійсний формат електронної пошти" + "uk": "Недійсний формат електронної пошти", + "ca": "Format de correu electrònic no vàlid" }, "FEEDBACK$FAILED_TO_SHARE": { "en": "Failed to share, please contact the developers:", @@ -4685,7 +4977,8 @@ "pt": "Falha ao compartilhar, entre em contato com os desenvolvedores:", "tr": "Paylaşım başarısız, lütfen geliştiricilerle iletişime geçin:", "ja": "共有に失敗しました。開発者に連絡してください:", - "uk": "Не вдалося поділитися, зв’яжіться з розробниками:" + "uk": "Не вдалося поділитися, зв’яжіться з розробниками:", + "ca": "No s'ha pogut compartir; contacteu amb els desenvolupadors:" }, "FEEDBACK$COPY_LABEL": { "en": "Copy", @@ -4701,7 +4994,8 @@ "pt": "Copiar", "tr": "Kopyala", "ja": "コピー", - "uk": "Копіювати" + "uk": "Копіювати", + "ca": "Copia" }, "FEEDBACK$SHARING_SETTINGS_LABEL": { "en": "Sharing settings", @@ -4717,7 +5011,8 @@ "pt": "Configurações de compartilhamento", "tr": "Paylaşım ayarları", "ja": "共有設定", - "uk": "Налаштування доступу" + "uk": "Налаштування доступу", + "ca": "Configuració de compartició" }, "SECURITY$UNKNOWN_ANALYZER_LABEL": { "en": "Unknown security analyzer chosen", @@ -4733,7 +5028,8 @@ "pt": "Analisador de segurança desconhecido escolhido", "tr": "Bilinmeyen güvenlik analizörü seçildi", "ja": "不明なセキュリティアナライザーが選択されました", - "uk": "Вибрано невідомий аналізатор безпеки" + "uk": "Вибрано невідомий аналізатор безпеки", + "ca": "S'ha seleccionat un analitzador de seguretat desconegut" }, "INVARIANT$UPDATE_POLICY_LABEL": { "en": "Update Policy", @@ -4749,7 +5045,8 @@ "pt": "Atualizar política", "tr": "İlkeyi güncelle", "ja": "ポリシーを更新", - "uk": "Політика оновлення" + "uk": "Політика оновлення", + "ca": "Actualitza la política" }, "INVARIANT$UPDATE_SETTINGS_LABEL": { "en": "Update Settings", @@ -4765,7 +5062,8 @@ "pt": "Atualizar configurações", "tr": "Ayarları güncelle", "ja": "設定を更新", - "uk": "Оновити налаштування" + "uk": "Оновити налаштування", + "ca": "Actualitza la configuració" }, "INVARIANT$SETTINGS_LABEL": { "en": "Settings", @@ -4781,7 +5079,8 @@ "pt": "Configurações", "tr": "Ayarlar", "ja": "設定", - "uk": "Налаштування" + "uk": "Налаштування", + "ca": "Configuració" }, "INVARIANT$ASK_CONFIRMATION_RISK_SEVERITY_LABEL": { "en": "Ask for user confirmation on risk severity:", @@ -4797,7 +5096,8 @@ "pt": "Solicitar confirmação do usuário sobre a gravidade do risco:", "tr": "Risk şiddeti için kullanıcı onayı iste:", "ja": "リスクの重大度についてユーザーの確認を求める:", - "uk": "Запит підтвердження користувача щодо ступеня ризику:" + "uk": "Запит підтвердження користувача щодо ступеня ризику:", + "ca": "Demana confirmació a l'usuari sobre la gravetat del risc:" }, "INVARIANT$DONT_ASK_FOR_CONFIRMATION_LABEL": { "en": "Don't ask for confirmation", @@ -4813,7 +5113,8 @@ "pt": "Não solicitar confirmação", "tr": "Onay isteme", "ja": "確認を求めない", - "uk": "Не запитувати підтвердження" + "uk": "Не запитувати підтвердження", + "ca": "No demanis confirmació" }, "INVARIANT$INVARIANT_ANALYZER_LABEL": { "en": "Invariant Analyzer", @@ -4829,7 +5130,8 @@ "pt": "Analisador de invariantes", "tr": "Değişmez Analizörü", "ja": "不変条件アナライザー", - "uk": "Інваріантний аналізатор" + "uk": "Інваріантний аналізатор", + "ca": "Analitzador Invariant" }, "INVARIANT$INVARIANT_ANALYZER_MESSAGE": { "en": "Invariant Analyzer continuously monitors your OpenHands agent for security issues.", @@ -4845,7 +5147,8 @@ "pt": "O analisador de invariantes monitora continuamente seu agente OpenHands em busca de problemas de segurança.", "tr": "Değişmez Analizörü, OpenHands ajanınızı güvenlik sorunları için sürekli olarak izler.", "ja": "不変条件アナライザーは、OpenHandsエージェントのセキュリティ問題を継続的に監視します。", - "uk": "Invariant Analyzer постійно контролює ваш агент OpenHands на наявність проблем безпеки." + "uk": "Invariant Analyzer постійно контролює ваш агент OpenHands на наявність проблем безпеки.", + "ca": "L'Analitzador Invariant monitoritza contínuament el vostre agent OpenHands per detectar problemes de seguretat." }, "INVARIANT$CLICK_TO_LEARN_MORE_LABEL": { "en": "Click to learn more", @@ -4861,7 +5164,8 @@ "pt": "Clique para saber mais", "tr": "Daha fazla bilgi için tıklayın", "ja": "詳細はこちらをクリック", - "uk": "Click to learn more" + "uk": "Click to learn more", + "ca": "Feu clic per saber-ne més" }, "INVARIANT$POLICY_LABEL": { "en": "Policy", @@ -4877,7 +5181,8 @@ "pt": "Política", "tr": "İlke", "ja": "ポリシー", - "uk": "Політика" + "uk": "Політика", + "ca": "Política" }, "INVARIANT$LOG_LABEL": { "en": "Logs", @@ -4893,7 +5198,8 @@ "pt": "Logs", "tr": "Günlükler", "ja": "ログ", - "uk": "Журнали" + "uk": "Журнали", + "ca": "Registres" }, "INVARIANT$EXPORT_TRACE_LABEL": { "en": "Export Trace", @@ -4909,7 +5215,8 @@ "pt": "Exportar rastreamento", "tr": "İzlemeyi dışa aktar", "ja": "トレースをエクスポート", - "uk": "Експорт трасування" + "uk": "Експорт трасування", + "ca": "Exporta el rastre" }, "INVARIANT$TRACE_EXPORTED_MESSAGE": { "en": "Trace exported", @@ -4925,7 +5232,8 @@ "pt": "Rastreamento exportado", "tr": "İzleme dışa aktarıldı", "ja": "トレースをエクスポートしました", - "uk": "Трасування експортовано" + "uk": "Трасування експортовано", + "ca": "Rastre exportat" }, "INVARIANT$POLICY_UPDATED_MESSAGE": { "en": "Policy updated", @@ -4941,7 +5249,8 @@ "pt": "Política atualizada", "tr": "İlke güncellendi", "ja": "ポリシーを更新しました", - "uk": "Policy updated" + "uk": "Policy updated", + "ca": "Política actualitzada" }, "INVARIANT$SETTINGS_UPDATED_MESSAGE": { "en": "Settings updated", @@ -4957,7 +5266,8 @@ "pt": "Configurações atualizadas", "tr": "Ayarlar güncellendi", "ja": "設定を更新しました", - "uk": "Налаштування оновлено" + "uk": "Налаштування оновлено", + "ca": "Configuració actualitzada" }, "CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE": { "en": "NEW FILES ADDED", @@ -4973,7 +5283,8 @@ "fr": "NOUVEAUX FICHIERS AJOUTÉS", "tr": "YENİ DOSYALAR EKLENDİ", "ja": "新しいファイルが追加されました", - "uk": "ДОДАНО НОВІ ФАЙЛИ" + "uk": "ДОДАНО НОВІ ФАЙЛИ", + "ca": "FITXERS NOUS AFEGITS" }, "CHAT_INTERFACE$DISCONNECTED": { "en": "Disconnected", @@ -4989,7 +5300,8 @@ "fr": "Déconnecté", "tr": "Bağlantı kesildi", "de": "Getrennt", - "uk": "Від'єднано" + "uk": "Від'єднано", + "ca": "Desconnectat" }, "CHAT_INTERFACE$COMMANDS": { "en": "Commands", @@ -5005,7 +5317,8 @@ "pt": "Comandos", "es": "Comandos", "tr": "Komutlar", - "uk": "Команди" + "uk": "Команди", + "ca": "Comandes" }, "CHAT_INTERFACE$CONNECTING": { "en": "Connecting... (this may take 1-2 minutes)", @@ -5021,7 +5334,8 @@ "fr": "Connexion en cours... (cela peut prendre 1-2 minutes)", "tr": "Bağlanıyor... (bu 1-2 dakika sürebilir)", "de": "Verbindung wird hergestellt... (dies kann 1-2 Minuten dauern)", - "uk": "З'єднання... (це може зайняти 1-2 хвилини)" + "uk": "З'єднання... (це може зайняти 1-2 хвилини)", + "ca": "Connectant... (això pot trigar 1-2 minuts)" }, "CHAT_INTERFACE$STOPPED": { "en": "Stopped", @@ -5037,7 +5351,8 @@ "fr": "Arrêté", "tr": "Durduruldu", "de": "Angehalten", - "uk": "Зупинено" + "uk": "Зупинено", + "ca": "Aturat" }, "CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": { "en": "Initializing agent...", @@ -5053,7 +5368,8 @@ "fr": "Initialisation de l'agent...", "tr": "Ajan başlatılıyor...", "ja": "エージェントを初期化中...", - "uk": "Ініціалізація агента..." + "uk": "Ініціалізація агента...", + "ca": "Inicialitzant l'agent..." }, "CHAT_INTERFACE$AGENT_INIT_MESSAGE": { "en": "Agent is initialized, waiting for task...", @@ -5069,7 +5385,8 @@ "fr": "L'agent est initialisé, en attente de tâche...", "tr": "Ajan başlatıldı, görev bekleniyor...", "ja": "エージェントの初期化が完了しました。タスクを待機中...", - "uk": "Агент ініціалізовано, очікує завдання..." + "uk": "Агент ініціалізовано, очікує завдання...", + "ca": "L'agent s'ha inicialitzat, esperant una tasca..." }, "CHAT_INTERFACE$AGENT_RUNNING_MESSAGE": { "en": "Agent is running task", @@ -5085,7 +5402,8 @@ "fr": "L'agent exécute la tâche", "tr": "Ajan görevi yürütüyor", "ja": "エージェントがタスクを実行中", - "uk": "Агент виконує завдання" + "uk": "Агент виконує завдання", + "ca": "L'agent està executant la tasca" }, "CHAT_INTERFACE$AGENT_AWAITING_USER_INPUT_MESSAGE": { "en": "Agent is awaiting user input...", @@ -5101,7 +5419,8 @@ "fr": "L'agent attend l'entrée de l'utilisateur...", "tr": "Ajan kullanıcı girdisini bekliyor...", "ja": "エージェントがユーザー入力を待機中...", - "uk": "Агент очікує на введення даних від користувача..." + "uk": "Агент очікує на введення даних від користувача...", + "ca": "L'agent espera la resposta de l'usuari..." }, "CHAT_INTERFACE$AGENT_RATE_LIMITED_MESSAGE": { "en": "Agent is Rate Limited. Retrying...", @@ -5117,7 +5436,8 @@ "fr": "L'agent est limité en fréquence. Nouvelle tentative...", "tr": "Ajan hız sınırına ulaştı. Yeniden deniyor...", "ja": "エージェントがレート制限中。再試行しています...", - "uk": "Агента обмежено кількістю запитів. Повторюємо спробу..." + "uk": "Агента обмежено кількістю запитів. Повторюємо спробу...", + "ca": "L'agent ha superat el límit de velocitat. Reintentant..." }, "CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE": { "en": "Agent is rate-limited. Stopped.", @@ -5133,7 +5453,8 @@ "fr": "L'agent est limité en fréquence. Arrêté.", "tr": "Ajan hız sınırına ulaştı. Durduruldu.", "ja": "エージェントがレート制限中。停止しました。", - "uk": "Агента обмежено кількістю запитів. Зупинено." + "uk": "Агента обмежено кількістю запитів. Зупинено.", + "ca": "L'agent ha superat el límit de velocitat. Aturat." }, "CHAT_INTERFACE$AGENT_PAUSED_MESSAGE": { "en": "Agent has paused.", @@ -5149,7 +5470,8 @@ "fr": "L'agent a mis en pause.", "tr": "Ajan duraklatıldı.", "ja": "エージェントが一時停止中", - "uk": "Агента призупинено." + "uk": "Агента призупинено.", + "ca": "L'agent ha fet una pausa." }, "LANDING$TITLE": { "en": "Let's start building!", @@ -5165,7 +5487,8 @@ "ar": "هيا نبدأ البناء!", "no": "La oss begynne å bygge!", "tr": "Hadi inşa etmeye başlayalım!", - "uk": "Почнімо будувати!" + "uk": "Почнімо будувати!", + "ca": "Comencem a construir!" }, "LANDING$SUBTITLE": { "en": "OpenHands makes it easy to build and maintain software using a simple prompt.", @@ -5181,7 +5504,8 @@ "ar": "يجعل OpenHands من السهل بناء وصيانة البرمجيات باستخدام موجه بسيط.", "no": "OpenHands gjør det enkelt å bygge og vedlikeholde programvare ved hjelp av en enkel prompt.", "tr": "Yapay zeka destekli yazılım geliştirme", - "uk": "OpenHands спрощує створення та підтримку програмного забезпечення за допомогою простого командного рядка." + "uk": "OpenHands спрощує створення та підтримку програмного забезпечення за допомогою простого командного рядка.", + "ca": "OpenHands facilita la construcció i el manteniment de programari amb un simple missatge." }, "LANDING$START_HELP": { "en": "Not sure how to start?", @@ -5197,7 +5521,8 @@ "ar": "غير متأكد من كيفية البدء؟", "no": "Usikker på hvordan du skal begynne?", "tr": "Başlamak için yardım", - "uk": "Не знаєте, як почати?" + "uk": "Не знаєте, як почати?", + "ca": "No esteu segur de com començar?" }, "LANDING$START_HELP_LINK": { "en": "Read this", @@ -5213,7 +5538,8 @@ "ar": "اقرأ هذا", "no": "Les dette", "tr": "Başlangıç kılavuzu", - "uk": "Прочитайте це" + "uk": "Прочитайте це", + "ca": "Llegiu això" }, "SUGGESTIONS$HELLO_WORLD": { "en": "Create a Hello World app", @@ -5229,7 +5555,8 @@ "ar": "إنشاء تطبيق Hello World", "no": "Lag en Hello World-app", "tr": "Bir Hello World uygulaması oluştur", - "uk": "Створіть додаток Hello World" + "uk": "Створіть додаток Hello World", + "ca": "Crea una aplicació Hola, món" }, "SUGGESTIONS$TODO_APP": { "en": "Build a todo list application", @@ -5245,7 +5572,8 @@ "ar": "بناء تطبيق قائمة المهام", "no": "Bygg en gjøremålsliste-app", "tr": "Yapılacaklar uygulaması", - "uk": "Створіть додаток для списку справ" + "uk": "Створіть додаток для списку справ", + "ca": "Construeix una aplicació de llista de tasques" }, "SUGGESTIONS$HACKER_NEWS": { "en": "Write a bash script that shows the top story on Hacker News", @@ -5261,7 +5589,8 @@ "ar": "كتابة سكربت باش يعرض أهم خبر على هاكر نيوز", "no": "Skriv et bash-script som viser topphistorien på Hacker News", "tr": "Hacker News klonu", - "uk": "Напишіть bash-скрипт, який показує головну новину на Hacker News" + "uk": "Напишіть bash-скрипт, який показує головну новину на Hacker News", + "ca": "Escriu un script bash que mostri la notícia principal de Hacker News" }, "LANDING$CHANGE_PROMPT": { "en": "What would you like to change in {{repo}}?", @@ -5277,7 +5606,8 @@ "ar": "ماذا تريد أن تغير في {{repo}}؟", "no": "Hva vil du endre i {{repo}}?", "tr": "İsteği değiştir", - "uk": "Що б ви хотіли змінити в {{repo}}?" + "uk": "Що б ви хотіли змінити в {{repo}}?", + "ca": "Què vols canviar a {{repo}}?" }, "GITHUB$CONNECT": { "en": "Connect to GitHub", @@ -5293,7 +5623,8 @@ "ar": "الاتصال بـ GitHub", "no": "Koble til GitHub", "tr": "GitHub'a bağlan", - "uk": "Підключитися до GitHub" + "uk": "Підключитися до GitHub", + "ca": "Connecta a GitHub" }, "GITHUB$NO_RESULTS": { "en": "No results found.", @@ -5309,7 +5640,8 @@ "uk": "Результатів не знайдено.", "no": "Ingen resultater funnet.", "ar": "لم يتم العثور على نتائج.", - "tr": "Sonuç bulunamadı." + "tr": "Sonuç bulunamadı.", + "ca": "No s'han trobat resultats." }, "GITHUB$LOADING_REPOSITORIES": { "en": "Loading repositories...", @@ -5325,7 +5657,8 @@ "ar": "لم يتم العثور على نتائج.", "no": "Ingen resultater funnet.", "tr": "Sonuç bulunamadı", - "uk": "Завантаження репозиторіїв..." + "uk": "Завантаження репозиторіїв...", + "ca": "Carregant repositoris..." }, "GITHUB$ADD_MORE_REPOS": { "en": "Add more repositories...", @@ -5341,7 +5674,8 @@ "ar": "إضافة المزيد من المستودعات...", "no": "Legg til flere repositories...", "tr": "Daha fazla depo ekle", - "uk": "Додати більше репозиторіїв..." + "uk": "Додати більше репозиторіїв...", + "ca": "Afegeix més repositoris..." }, "GITHUB$YOUR_REPOS": { "en": "Your Repos", @@ -5357,7 +5691,8 @@ "ar": "مستودعاتك", "no": "Dine repositories", "tr": "Depolarınız", - "uk": "Ваші репозиторії" + "uk": "Ваші репозиторії", + "ca": "Els teus repositoris" }, "GITHUB$PUBLIC_REPOS": { "en": "Public Repos", @@ -5373,7 +5708,8 @@ "ar": "المستودعات العامة", "no": "Offentlige repositories", "tr": "Herkese açık depolar", - "uk": "Публічні репозиторії" + "uk": "Публічні репозиторії", + "ca": "Repositoris públics" }, "DOWNLOAD$PREPARING": { "en": "Preparing Download...", @@ -5389,7 +5725,8 @@ "ar": "جاري تحضير التنزيل...", "no": "Forbereder nedlasting...", "tr": "Hazırlanıyor", - "uk": "Підготовка завантаження..." + "uk": "Підготовка завантаження...", + "ca": "Preparant la descàrrega..." }, "DOWNLOAD$DOWNLOADING": { "en": "Downloading Files", @@ -5405,7 +5742,8 @@ "ar": "جاري تنزيل الملفات", "no": "Laster ned filer", "tr": "İndiriliyor", - "uk": "Завантаження файлів" + "uk": "Завантаження файлів", + "ca": "Descarregant fitxers" }, "DOWNLOAD$FOUND_FILES": { "en": "Found {{count}} files...", @@ -5421,7 +5759,8 @@ "ar": "تم العثور على {{count}} ملف...", "no": "Fant {{count}} filer...", "tr": "Dosyalar bulundu", - "uk": "Знайдено {{count}} файлів..." + "uk": "Знайдено {{count}} файлів...", + "ca": "S'han trobat {{count}} fitxers..." }, "DOWNLOAD$SCANNING": { "en": "Scanning workspace...", @@ -5437,7 +5776,8 @@ "ar": "جاري فحص مساحة العمل...", "no": "Skanner arbeidsområde...", "tr": "Taranıyor", - "uk": "Сканування робочого простору..." + "uk": "Сканування робочого простору...", + "ca": "Analitzant l'espai de treball..." }, "DOWNLOAD$FILES_PROGRESS": { "en": "{{downloaded}} of {{total}} files", @@ -5453,7 +5793,8 @@ "ar": "{{downloaded}} من {{total}} ملف", "no": "{{downloaded}} av {{total}} filer", "tr": "Dosya ilerleme durumu", - "uk": "{{downloaded}} з {{total}} файлів" + "uk": "{{downloaded}} з {{total}} файлів", + "ca": "{{downloaded}} de {{total}} fitxers" }, "DOWNLOAD$CANCEL": { "en": "Cancel", @@ -5469,7 +5810,8 @@ "ar": "إلغاء", "no": "Avbryt", "tr": "İptal", - "uk": "Скасувати" + "uk": "Скасувати", + "ca": "Cancel·la" }, "ACTION$CONFIRM": { "en": "Confirm action", @@ -5485,7 +5827,8 @@ "ar": "تأكيد الإجراء", "no": "Bekreft handling", "tr": "Onayla", - "uk": "Підтвердити дію" + "uk": "Підтвердити дію", + "ca": "Confirma l'acció" }, "ACTION$REJECT": { "en": "Reject action", @@ -5501,7 +5844,8 @@ "ar": "رفض الإجراء", "no": "Avvis handling", "tr": "Reddet", - "uk": "Відхилити дію" + "uk": "Відхилити дію", + "ca": "Rebutja l'acció" }, "BADGE$BETA": { "en": "Beta", @@ -5517,7 +5861,8 @@ "ar": "تجريبي", "no": "Beta", "tr": "Beta", - "uk": "Бета" + "uk": "Бета", + "ca": "Beta" }, "AGENT$RESUME_TASK": { "en": "Resume the agent task", @@ -5533,7 +5878,8 @@ "ar": "استئناف مهمة الوكيل", "no": "Gjenoppta agentoppgaven", "tr": "Görevi devam ettir", - "uk": "Відновити завдання агента" + "uk": "Відновити завдання агента", + "ca": "Reprèn la tasca de l'agent" }, "AGENT$PAUSE_TASK": { "en": "Pause the current task", @@ -5549,7 +5895,8 @@ "ar": "إيقاف المهمة الحالية مؤقتًا", "no": "Pause gjeldende oppgave", "tr": "Görevi duraklat", - "uk": "Призупинити поточне завдання" + "uk": "Призупинити поточне завдання", + "ca": "Posa en pausa la tasca actual" }, "TOS$ACCEPT": { "en": "I accept the", @@ -5565,7 +5912,8 @@ "ar": "أوافق على", "no": "Jeg godtar", "tr": "Kabul et", - "uk": "Я приймаю" + "uk": "Я приймаю", + "ca": "Accepto les" }, "TOS$TERMS": { "en": "terms of service", @@ -5581,7 +5929,8 @@ "ar": "شروط الخدمة", "no": "vilkårene for bruk", "tr": "Kullanım şartları", - "uk": "умови обслуговування" + "uk": "умови обслуговування", + "ca": "condicions del servei" }, "USER$ACCOUNT_SETTINGS": { "en": "Account settings", @@ -5597,7 +5946,8 @@ "ar": "إعدادات الحساب", "no": "Kontoinnstillinger", "tr": "Hesap ayarları", - "uk": "Налаштування облікового запису" + "uk": "Налаштування облікового запису", + "ca": "Configuració del compte" }, "JUPYTER$OUTPUT_LABEL": { "en": "STDOUT/STDERR", @@ -5613,7 +5963,8 @@ "ar": "المخرجات القياسية/الأخطاء القياسية", "no": "STDOUT/STDERR", "tr": "Çıktı", - "uk": "STDOUT/STDERR" + "uk": "STDOUT/STDERR", + "ca": "STDOUT/STDERR" }, "BUTTON$STOP": { "en": "Stop", @@ -5629,7 +5980,8 @@ "ar": "توقف", "no": "Stopp", "tr": "Durdur", - "uk": "Стоп" + "uk": "Стоп", + "ca": "Atura" }, "BUTTON$PAUSE": { "en": "Pause", @@ -5645,7 +5997,8 @@ "ar": "إيقاف مؤقت", "no": "Pause", "tr": "Duraklat", - "uk": "Призупинити" + "uk": "Призупинити", + "ca": "Pausa" }, "BUTTON$EDIT_TITLE": { "en": "Edit Title", @@ -5661,7 +6014,8 @@ "ar": "تحرير العنوان", "no": "Rediger tittel", "tr": "Başlığı Düzenle", - "uk": "Редагувати заголовок" + "uk": "Редагувати заголовок", + "ca": "Edita el títol" }, "BUTTON$DOWNLOAD_VIA_VSCODE": { "en": "Download via VS Code", @@ -5677,7 +6031,8 @@ "ar": "تحميل عبر VS Code", "no": "Last ned via VS Code", "tr": "VS Code ile İndir", - "uk": "Завантажити через VS Code" + "uk": "Завантажити через VS Code", + "ca": "Descarrega via VS Code" }, "BUTTON$DISPLAY_COST": { "en": "Display Cost", @@ -5693,7 +6048,8 @@ "ar": "عرض التكلفة", "no": "Vis kostnad", "tr": "Maliyeti Göster", - "uk": "Показати вартість" + "uk": "Показати вартість", + "ca": "Mostra el cost" }, "BUTTON$SHOW_AGENT_TOOLS_AND_METADATA": { "en": "Show Agent Tools & Metadata", @@ -5709,7 +6065,8 @@ "ar": "عرض أدوات الوكيل والبيانات الوصفية", "no": "Vis agentverktøy og metadata", "tr": "Ajan Araçları ve Meta Verileri Göster", - "uk": "Показати інструменти агента та метадані" + "uk": "Показати інструменти агента та метадані", + "ca": "Mostra les eines i metadades de l'agent" }, "LANDING$ATTACH_IMAGES": { "en": "Attach images", @@ -5725,7 +6082,8 @@ "ar": "إرفاق صور", "no": "Legg ved bilder", "tr": "Resim ekle", - "uk": "Додати зображення" + "uk": "Додати зображення", + "ca": "Adjunta imatges" }, "LANDING$OPEN_REPO": { "en": "Open a Repo", @@ -5741,7 +6099,8 @@ "ar": "فتح مستودع", "no": "Åpne et repo", "tr": "Depo aç", - "uk": "Відкрити репозиторій" + "uk": "Відкрити репозиторій", + "ca": "Obre un repositori" }, "LANDING$REPLAY": { "en": "+ Replay Trajectory", @@ -5757,7 +6116,8 @@ "ar": "+ إعادة تشغيل المسار", "no": "+ Spill av trajektori", "tr": "+ Yörüngeyi yeniden oynat", - "uk": "+ Відтворити траєкторію" + "uk": "+ Відтворити траєкторію", + "ca": "+ Reprodueix la trajectòria" }, "LANDING$UPLOAD_TRAJECTORY": { "en": "Upload a .json", @@ -5773,7 +6133,8 @@ "no": "Last opp en .json-fil", "tr": ".json dosyası yükle", "uk": "Завантажити файл .json", - "ja": ".jsonファイルをアップロード" + "ja": ".jsonファイルをアップロード", + "ca": "Carrega un fitxer .json" }, "LANDING$RECENT_CONVERSATION": { "en": "jump back to your most recent conversation", @@ -5789,7 +6150,8 @@ "ar": "أو العودة إلى آخر محادثة", "no": "gå tilbake til din siste samtale", "tr": "Son konuşma", - "uk": "повернутися до вашої останньої розмови" + "uk": "повернутися до вашої останньої розмови", + "ca": "torna a la conversa més recent" }, "CONVERSATION$CONFIRM_DELETE": { "en": "Confirm Delete", @@ -5805,7 +6167,8 @@ "fr": "Confirmer la suppression", "tr": "Silmeyi Onayla", "de": "Löschen bestätigen", - "uk": "Підтвердити видалення" + "uk": "Підтвердити видалення", + "ca": "Confirma l'eliminació" }, "CONVERSATION$CONFIRM_STOP": { "en": "Confirm Stop", @@ -5821,7 +6184,8 @@ "fr": "Confirmer l'arrêt", "tr": "Durdurmayı Onayla", "de": "Stopp bestätigen", - "uk": "Підтвердити зупинку" + "uk": "Підтвердити зупинку", + "ca": "Confirma l'aturada" }, "CONVERSATION$CONFIRM_PAUSE": { "en": "Confirm Pause", @@ -5837,7 +6201,8 @@ "fr": "Confirmer la mise en pause", "tr": "Duraklatmayı Onayla", "de": "Pause bestätigen", - "uk": "Підтвердити призупинення" + "uk": "Підтвердити призупинення", + "ca": "Confirma la pausa" }, "CONVERSATION$PAUSE_WARNING": { "en": "Are you sure you want to pause this conversation?", @@ -5853,7 +6218,8 @@ "fr": "Êtes-vous sûr de vouloir mettre cette conversation en pause ?", "tr": "Bu konuşmayı duraklatmak istediğinizden emin misiniz?", "de": "Sind Sie sicher, dass Sie dieses Gespräch pausieren möchten?", - "uk": "Ви впевнені, що хочете призупинити цю розмову?" + "uk": "Ви впевнені, що хочете призупинити цю розмову?", + "ca": "Esteu segur que voleu posar en pausa aquesta conversa?" }, "CONVERSATION$CONFIRM_CLOSE_CONVERSATION": { "en": "Confirm Stop Sandbox", @@ -5869,7 +6235,8 @@ "fr": "Confirmer l'arrêt du sandbox", "tr": "Sandbox'ı Durdurmayı Onayla", "de": "Sandbox-Stopp bestätigen", - "uk": "Підтвердити зупинку пісочниці" + "uk": "Підтвердити зупинку пісочниці", + "ca": "Confirma l'aturada del sandbox" }, "CONVERSATION$CLOSE_CONVERSATION_WARNING": { "en": "This will stop the sandbox, and pause the following conversations:", @@ -5885,7 +6252,8 @@ "fr": "Cela arrêtera le sandbox et mettra en pause les conversations suivantes :", "tr": "Bu, sandbox'ı durduracak ve aşağıdaki konuşmaları duraklatacaktır:", "de": "Dies wird die Sandbox stoppen und die folgenden Gespräche pausieren:", - "uk": "Це зупинить пісочницю та призупинить наступні розмови:" + "uk": "Це зупинить пісочницю та призупинить наступні розмови:", + "ca": "Això aturarà el sandbox i posarà en pausa les converses següents:" }, "CONVERSATION$STOP_WARNING": { "en": "Are you sure you want to pause this conversation?", @@ -5901,7 +6269,8 @@ "fr": "Êtes-vous sûr de vouloir arrêter cette conversation ?", "tr": "Bu konuşmayı durdurmak istediğinizden emin misiniz?", "de": "Sind Sie sicher, dass Sie dieses Gespräch stoppen möchten?", - "uk": "Ви впевнені, що хочете зупинити цю розмову?" + "uk": "Ви впевнені, що хочете зупинити цю розмову?", + "ca": "Esteu segur que voleu posar en pausa aquesta conversa?" }, "CONVERSATION$METRICS_INFO": { "en": "Conversation Metrics", @@ -5917,7 +6286,8 @@ "fr": "Métriques de conversation", "tr": "Konuşma Metrikleri", "de": "Gesprächsmetriken", - "uk": "Метрики розмов" + "uk": "Метрики розмов", + "ca": "Mètriques de la conversa" }, "CONVERSATION$CREATED": { "en": "Created", @@ -5933,7 +6303,8 @@ "ar": "تم الإنشاء", "fr": "Créé", "tr": "Oluşturuldu", - "uk": "Створено" + "uk": "Створено", + "ca": "Creat" }, "CONVERSATION$AGO": { "en": "ago", @@ -5949,7 +6320,8 @@ "ar": "منذ", "fr": "il y a", "tr": "önce", - "uk": "тому" + "uk": "тому", + "ca": "fa" }, "GITHUB$VSCODE_LINK_DESCRIPTION": { "en": "and use the VS Code link to upload and download your code", @@ -5965,7 +6337,8 @@ "ar": "واستخدم رابط VS Code لتحميل وتنزيل الكود الخاص بك", "fr": "et utilisez le lien VS Code pour télécharger et téléverser votre code", "tr": "ve kodunuzu yüklemek ve indirmek için VS Code bağlantısını kullanın", - "uk": "і скористайтеся посиланням VS Code, щоб завантажити свій код" + "uk": "і скористайтеся посиланням VS Code, щоб завантажити свій код", + "ca": "i feu servir l'enllaç de VS Code per carregar i descarregar el vostre codi" }, "CONVERSATION$EXIT_WARNING": { "en": "Exit Conversation", @@ -5981,7 +6354,8 @@ "fr": "Quitter la conversation", "tr": "Konuşmadan Çık", "de": "Gespräch verlassen", - "uk": "Вийти з розмови" + "uk": "Вийти з розмови", + "ca": "Surt de la conversa" }, "CONVERSATION$TITLE_UPDATED": { "en": "Conversation title updated successfully", @@ -5997,7 +6371,8 @@ "ar": "تم تحديث عنوان المحادثة بنجاح", "fr": "Titre de la conversation mis à jour avec succès", "tr": "Konuşma başlığı başarıyla güncellendi", - "uk": "Назву розмови успішно оновлено" + "uk": "Назву розмови успішно оновлено", + "ca": "El títol de la conversa s'ha actualitzat correctament" }, "LANDING$OR": { "en": "Or", @@ -6013,7 +6388,8 @@ "ar": "أو", "no": "Eller", "tr": "veya", - "uk": "Або" + "uk": "Або", + "ca": "O" }, "SUGGESTIONS$TEST_COVERAGE": { "en": "Increase my test coverage", @@ -6029,7 +6405,8 @@ "ar": "زيادة تغطية الاختبار", "no": "Øk min testdekning", "tr": "Test kapsamı", - "uk": "Збільшити моє охоплення тестами" + "uk": "Збільшити моє охоплення тестами", + "ca": "Augmenta la meva cobertura de proves" }, "SUGGESTIONS$AUTO_MERGE": { "en": "Auto-merge Dependabot PRs", @@ -6045,7 +6422,8 @@ "ar": "دمج تلقائي لطلبات سحب Dependabot", "no": "Auto-flett Dependabot PRs", "tr": "Otomatik birleştirme", - "uk": "Автоматичне об'єднання Dependabot PR" + "uk": "Автоматичне об'єднання Dependabot PR", + "ca": "Fusió automàtica de les PR de Dependabot" }, "CHAT_INTERFACE$AGENT_STOPPED_MESSAGE": { "en": "Agent has stopped.", @@ -6061,7 +6439,8 @@ "fr": "L'agent s'est arrêté.", "tr": "Ajan durdu.", "ja": "エージェントが停止しました", - "uk": "Агент зупинився." + "uk": "Агент зупинився.", + "ca": "L'agent s'ha aturat." }, "CHAT_INTERFACE$AGENT_FINISHED_MESSAGE": { "en": "Agent has finished the task.", @@ -6077,7 +6456,8 @@ "fr": "L'agent a terminé la tâche.", "tr": "Ajan görevi tamamladı.", "ja": "エージェントがタスクを完了しました", - "uk": "Агент виконав завдання." + "uk": "Агент виконав завдання.", + "ca": "L'agent ha acabat la tasca." }, "CHAT_INTERFACE$AGENT_REJECTED_MESSAGE": { "en": "Agent has rejected the task.", @@ -6093,7 +6473,8 @@ "fr": "L'agent a rejeté la tâche.", "tr": "Ajan görevi reddetti.", "ja": "エージェントがタスクを拒否しました", - "uk": "Агент відхилив завдання." + "uk": "Агент відхилив завдання.", + "ca": "L'agent ha rebutjat la tasca." }, "CHAT_INTERFACE$AGENT_ERROR_MESSAGE": { "en": "Agent encountered an error.", @@ -6109,7 +6490,8 @@ "fr": "L'agent a rencontré une erreur.", "tr": "Ajan bir hatayla karşılaştı.", "ja": "エージェントでエラーが発生しました", - "uk": "Агент зіткнувся з помилкою." + "uk": "Агент зіткнувся з помилкою.", + "ca": "L'agent ha trobat un error." }, "CHAT_INTERFACE$AGENT_AWAITING_USER_CONFIRMATION_MESSAGE": { "en": "Agent is awaiting user confirmation for the pending action.", @@ -6125,7 +6507,8 @@ "fr": "L'agent attend la confirmation de l'utilisateur pour l'action en attente.", "tr": "Ajan, bekleyen işlem için kullanıcı onayını bekliyor.", "ja": "ユーザーの確認を待っています", - "uk": "Агент очікує підтвердження користувача для дії, що очікує на виконання." + "uk": "Агент очікує підтвердження користувача для дії, що очікує на виконання.", + "ca": "L'agent espera la confirmació de l'usuari per a l'acció pendent." }, "CHAT_INTERFACE$AGENT_ACTION_USER_CONFIRMED_MESSAGE": { "en": "Agent action has been confirmed!", @@ -6141,7 +6524,8 @@ "fr": "L'action de l'agent a été confirmée !", "tr": "Ajan eylemi onaylandı!", "ja": "ユーザーがアクションを承認しました", - "uk": "Дію агента підтверджено!" + "uk": "Дію агента підтверджено!", + "ca": "L'acció de l'agent ha estat confirmada!" }, "CHAT_INTERFACE$AGENT_ACTION_USER_REJECTED_MESSAGE": { "en": "Agent action has been rejected!", @@ -6157,7 +6541,8 @@ "fr": "L'action de l'agent a été rejetée !", "tr": "Ajan eylemi reddedildi!", "ja": "ユーザーがアクションを拒否しました", - "uk": "Дію агента відхилено!" + "uk": "Дію агента відхилено!", + "ca": "L'acció de l'agent ha estat rebutjada!" }, "CHAT_INTERFACE$INPUT_PLACEHOLDER": { "en": "Message assistant...", @@ -6173,7 +6558,8 @@ "fr": "Envoyez un message à l'assistant...", "tr": "Asistana mesaj gönder...", "ja": "メッセージを入力してください...", - "uk": "Написати помічнику..." + "uk": "Написати помічнику...", + "ca": "Envia un missatge a l'assistent..." }, "CHAT_INTERFACE$INPUT_CONTINUE_MESSAGE": { "en": "Continue", @@ -6189,7 +6575,8 @@ "fr": "Continuer", "tr": "Devam et", "ja": "続行するにはEnterを押してください", - "uk": "Продовжити" + "uk": "Продовжити", + "ca": "Continua" }, "CHAT_INTERFACE$USER_ASK_CONFIRMATION": { "en": "Do you want to continue with this action?", @@ -6205,7 +6592,8 @@ "fr": "Voulez-vous continuer avec cette action ?", "tr": "Bu işleme devam etmek istiyor musunuz?", "ja": "このアクションを実行してもよろしいですか?", - "uk": "Ви хочете продовжити цю дію?" + "uk": "Ви хочете продовжити цю дію?", + "ca": "Voleu continuar amb aquesta acció?" }, "CHAT_INTERFACE$HIGH_RISK_WARNING": { "en": "Review carefully before proceeding.", @@ -6221,7 +6609,8 @@ "fr": "Examinez attentivement avant de continuer.", "tr": "Devam etmeden önce dikkatlice gözden geçirin.", "ja": "続行する前に慎重に確認してください。", - "uk": "Уважно перевірте перед продовженням." + "uk": "Уважно перевірте перед продовженням.", + "ca": "Reviseu-ho amb cura abans de continuar." }, "CHAT_INTERFACE$USER_CONFIRMED": { "en": "Confirm the requested action", @@ -6237,7 +6626,8 @@ "fr": "Confirmer l'action demandée", "tr": "İstenen eylemi onayla", "ja": "ユーザーが承認しました", - "uk": "Підтвердьте запитувану дію" + "uk": "Підтвердьте запитувану дію", + "ca": "Confirma l'acció sol·licitada" }, "CHAT_INTERFACE$USER_REJECTED": { "en": "Reject the requested action", @@ -6253,7 +6643,8 @@ "fr": "Rejeter l'action demandée", "tr": "İstenen eylemi reddet", "ja": "ユーザーが拒否しました", - "uk": "Відхилити запитувану дію" + "uk": "Відхилити запитувану дію", + "ca": "Rebutja l'acció sol·licitada" }, "CHAT_INTERFACE$INPUT_SEND_MESSAGE_BUTTON_CONTENT": { "en": "Send", @@ -6269,7 +6660,8 @@ "fr": "Envoyer", "ja": "送信", "tr": "Gönder", - "uk": "Надіслати" + "uk": "Надіслати", + "ca": "Envia" }, "CHAT_INTERFACE$CHAT_MESSAGE_COPIED": { "en": "Message copied to clipboard", @@ -6285,7 +6677,8 @@ "fr": "Message copié dans le presse-papiers", "tr": "Mesaj panoya kopyalandı", "ja": "メッセージをコピーしました", - "uk": "Повідомлення скопійовано в буфер обміну" + "uk": "Повідомлення скопійовано в буфер обміну", + "ca": "Missatge copiat al porta-retalls" }, "CHAT_INTERFACE$CHAT_MESSAGE_COPY_FAILED": { "en": "Failed to copy message to clipboard", @@ -6301,7 +6694,8 @@ "fr": "Échec de la copie du message dans le presse-papiers", "tr": "Mesaj panoya kopyalanamadı", "ja": "メッセージのコピーに失敗しました", - "uk": "Не вдалося скопіювати повідомлення в буфер обміну" + "uk": "Не вдалося скопіювати повідомлення в буфер обміну", + "ca": "No s'ha pogut copiar el missatge al porta-retalls" }, "CHAT_INTERFACE$TOOLTIP_COPY_MESSAGE": { "en": "Copy message", @@ -6317,7 +6711,8 @@ "fr": "Copier le message", "tr": "Mesajı kopyala", "ja": "メッセージをコピー", - "uk": "Копіювати повідомлення" + "uk": "Копіювати повідомлення", + "ca": "Copia el missatge" }, "CHAT_INTERFACE$TOOLTIP_SEND_MESSAGE": { "en": "Send message", @@ -6333,7 +6728,8 @@ "fr": "Envoyer le message", "tr": "Mesaj gönder", "ja": "メッセージを送信", - "uk": "Надіслати повідомлення" + "uk": "Надіслати повідомлення", + "ca": "Envia el missatge" }, "CHAT_INTERFACE$TOOLTIP_UPLOAD_IMAGE": { "en": "Upload image", @@ -6349,7 +6745,8 @@ "fr": "Télécharger une image", "tr": "Resim yükle", "ja": "画像をアップロード", - "uk": "Завантажити зображення" + "uk": "Завантажити зображення", + "ca": "Carrega una imatge" }, "CHAT_INTERFACE$INITIAL_MESSAGE": { "en": "Hi! I'm OpenHands, an AI Software Engineer. What would you like to build with me today?", @@ -6365,7 +6762,8 @@ "fr": "Salut! Je suis OpenHands, un ingénieur logiciel en IA. Que voudriez-vous construire avec moi aujourd'hui?", "ja": "何を作りたいですか?", "tr": "Ne yapmak istersiniz?", - "uk": "Привіт! Я OpenHands, ШІ інженер-програміст. Що б ви хотіли створити зі мною сьогодні?" + "uk": "Привіт! Я OpenHands, ШІ інженер-програміст. Що б ви хотіли створити зі мною сьогодні?", + "ca": "Hola! Soc l'OpenHands, un enginyer de programari d'IA. Què vols construir avui?" }, "CHAT_INTERFACE$ASSISTANT": { "en": "Assistant", @@ -6381,7 +6779,8 @@ "fr": "Assistant", "tr": "Gönder", "ja": "アシスタント", - "uk": "Помічник" + "uk": "Помічник", + "ca": "Assistent" }, "CHAT_INTERFACE$TO_BOTTOM": { "en": "To Bottom", @@ -6397,7 +6796,8 @@ "fr": "Vers le bas", "tr": "En alta", "ja": "一番下へ", - "uk": "Вниз" + "uk": "Вниз", + "ca": "Ves al final" }, "CHAT_INTERFACE$MESSAGE_ARIA_LABEL": { "en": "Message from {{sender}}", @@ -6413,7 +6813,8 @@ "fr": "Message de {{sender}}", "tr": "{{sender}} tarafından gönderilen mesaj", "ja": "メッセージ", - "uk": "Повідомлення від {{sender}}" + "uk": "Повідомлення від {{sender}}", + "ca": "Missatge de {{sender}}" }, "CHAT_INTERFACE$CHAT_CONVERSATION": { "en": "Chat Conversation", @@ -6429,7 +6830,8 @@ "fr": "Conversation de chat", "tr": "Sohbet Konuşması", "ja": "チャット会話", - "uk": "Розмова в чаті" + "uk": "Розмова в чаті", + "ca": "Conversa de xat" }, "CHAT_INTERFACE$UNKNOWN_SENDER": { "en": "Unknown", @@ -6445,7 +6847,8 @@ "fr": "Inconnu", "tr": "Bilinmeyen", "ja": "不明な送信者", - "uk": "Невідомий" + "uk": "Невідомий", + "ca": "Desconegut" }, "SETTINGS$MODEL_TOOLTIP": { "en": "Select the language model to use.", @@ -6461,7 +6864,8 @@ "fr": "Sélectionnez le modèle de langage à utiliser.", "tr": "Kullanılacak dil modelini seçin.", "ja": "使用するモデルを選択", - "uk": "Виберіть мовну модель, яку потрібно використовувати." + "uk": "Виберіть мовну модель, яку потрібно використовувати.", + "ca": "Selecciona el model de llenguatge que vols fer servir." }, "SETTINGS$AGENT_TOOLTIP": { "en": "Select the agent to use.", @@ -6477,7 +6881,8 @@ "fr": "Sélectionnez l'agent à utiliser.", "tr": "Kullanılacak ajanı seçin.", "ja": "使用するエージェントを選択", - "uk": "Виберіть агента для використання." + "uk": "Виберіть агента для використання.", + "ca": "Selecciona l'agent que vols fer servir." }, "SETTINGS$LANGUAGE_TOOLTIP": { "en": "Select the language for the UI.", @@ -6493,7 +6898,8 @@ "fr": "Sélectionnez la langue de l'interface utilisateur.", "tr": "Kullanıcı arayüzü için dil seçin.", "ja": "インターフェースの言語を選択", - "uk": "Виберіть мову для інтерфейсу користувача." + "uk": "Виберіть мову для інтерфейсу користувача.", + "ca": "Selecciona l'idioma de la interfície." }, "SETTINGS$DISABLED_RUNNING": { "en": "Cannot be changed while the agent is running.", @@ -6509,7 +6915,8 @@ "fr": "Ne peut pas être modifié pendant que l'agent est en cours d'exécution.", "tr": "Ajan çalışırken değiştirilemez.", "ja": "エージェントの実行中は設定を変更できません", - "uk": "Не можна змінити, поки агент працює." + "uk": "Не можна змінити, поки агент працює.", + "ca": "No es pot canviar mentre l'agent s'executa." }, "SETTINGS$API_KEY_PLACEHOLDER": { "en": "Enter your API key.", @@ -6525,7 +6932,8 @@ "fr": "Entrez votre clé API.", "tr": "API anahtarınızı girin.", "ja": "APIキーを入力", - "uk": "Введіть свій ключ API." + "uk": "Введіть свій ключ API.", + "ca": "Introduïu la vostra clau d'API." }, "SETTINGS$LLM_API_KEY": { "en": "OpenHands LLM Key", @@ -6541,7 +6949,8 @@ "fr": "Clé LLM OpenHands", "tr": "OpenHands LLM Anahtarı", "ja": "OpenHands LLMキー", - "uk": "Ключ LLM OpenHands" + "uk": "Ключ LLM OpenHands", + "ca": "Clau LLM d'OpenHands" }, "SETTINGS$LLM_API_KEY_DESCRIPTION": { "en": "You can use this API Key as the LLM API Key for OpenHands open-source and CLI. It will incur cost on your OpenHands Cloud account. Do NOT share this key elsewhere.", @@ -6557,7 +6966,8 @@ "fr": "Vous pouvez utiliser cette clé API comme clé API LLM pour OpenHands open-source et CLI. Cela entraînera des coûts sur votre compte OpenHands Cloud. NE partagez PAS cette clé ailleurs.", "tr": "Bu API Anahtarını, OpenHands açık kaynak ve CLI için LLM API Anahtarı olarak kullanabilirsiniz. OpenHands Cloud hesabınızda maliyet oluşturacaktır. Bu anahtarı başka yerlerde paylaşmayın.", "ja": "このAPIキーをOpenHandsオープンソースおよびCLIのLLM APIキーとして使用できます。OpenHands Cloudアカウントに費用が発生します。このキーを他の場所で共有しないでください。", - "uk": "Ви можете використовувати цей ключ API як ключ API LLM для OpenHands з відкритим кодом та CLI. Це призведе до витрат на вашому обліковому записі OpenHands Cloud. НЕ діліться цим ключем деінде." + "uk": "Ви можете використовувати цей ключ API як ключ API LLM для OpenHands з відкритим кодом та CLI. Це призведе до витрат на вашому обліковому записі OpenHands Cloud. НЕ діліться цим ключем деінде.", + "ca": "Podeu fer servir aquesta clau d'API com a clau d'API de LLM per al codi obert i la CLI d'OpenHands. Generarà costos al vostre compte d'OpenHands Cloud. NO compartiu aquesta clau en cap altre lloc." }, "SETTINGS$REFRESH_LLM_API_KEY": { "en": "Refresh API Key", @@ -6573,7 +6983,8 @@ "fr": "Actualiser la clé API", "tr": "API Anahtarını Yenile", "ja": "APIキーを更新", - "uk": "Оновити ключ API" + "uk": "Оновити ключ API", + "ca": "Actualitza la clau d'API" }, "SETTINGS$LLM_API_KEY_PAYWALL_MESSAGE": { "en": "Purchase at least $10 in credits to get access to OpenHands LLM key for use with OpenHands CLI and SDK.", @@ -6589,7 +7000,8 @@ "fr": "Achetez au moins 10$ de crédits pour accéder à la clé LLM OpenHands à utiliser avec OpenHands CLI et SDK.", "tr": "OpenHands CLI ve SDK ile kullanmak için OpenHands LLM Anahtarına erişim sağlamak için en az 10$ kredi satın alın.", "ja": "OpenHands CLIおよびSDKで使用するOpenHands LLMキーにアクセスするには、少なくとも$10のクレジットを購入してください。", - "uk": "Придбайте кредитів мінімум на $10, щоб отримати доступ до ключа LLM OpenHands для використання з OpenHands CLI та SDK." + "uk": "Придбайте кредитів мінімум на $10, щоб отримати доступ до ключа LLM OpenHands для використання з OpenHands CLI та SDK.", + "ca": "Compreu almenys 10 $ en crèdits per accedir a la clau LLM d'OpenHands per a la CLI i l'SDK d'OpenHands." }, "SETTINGS$LLM_API_KEY_BUY_NOW": { "en": "Buy Now", @@ -6605,7 +7017,8 @@ "fr": "Acheter maintenant", "tr": "Şimdi Satın Al", "ja": "今すぐ購入", - "uk": "Купити зараз" + "uk": "Купити зараз", + "ca": "Compra ara" }, "SETTINGS$CONFIRMATION_MODE": { "en": "Enable Confirmation Mode", @@ -6621,7 +7034,8 @@ "fr": "Activer le mode de confirmation", "tr": "Onay Modunu Etkinleştir", "ja": "確認モード", - "uk": "Увімкнути режим підтвердження" + "uk": "Увімкнути режим підтвердження", + "ca": "Activa el mode de confirmació" }, "SETTINGS$CONFIRMATION_MODE_TOOLTIP": { "en": "Awaits for user confirmation before executing code.", @@ -6637,7 +7051,8 @@ "fr": "Attend la confirmation de l'utilisateur avant d'exécuter le code.", "tr": "Kodu çalıştırmadan önce kullanıcı onayını bekler.", "ja": "エージェントのアクションを実行前に確認", - "uk": "Очікує підтвердження користувача перед виконанням коду." + "uk": "Очікує підтвердження користувача перед виконанням коду.", + "ca": "Espera la confirmació de l'usuari abans d'executar el codi." }, "SETTINGS$CONFIRMATION_MODE_LOCK_TOOLTIP": { "en": "The agent is in confirmation mode. It will prompt the user to confirm certain actions when security analyzer policy detected a high-risk action. Click this icon to go to settings tab for more information.", @@ -6653,7 +7068,8 @@ "fr": "L'agent est en mode de confirmation. Il demandera à l'utilisateur de confirmer certaines actions lorsque la politique de l'analyseur de sécurité détecte une action à haut risque. Consultez l'onglet paramètres pour plus d'informations.", "tr": "Ajan onay modunda. Güvenlik analizörü politikası yüksek riskli bir eylem tespit ettiğinde kullanıcıdan belirli eylemleri onaylamasını isteyecek. Daha fazla bilgi için ayarlar sekmesini kontrol edin.", "ja": "エージェントは確認モードです。セキュリティアナライザーポリシーが高リスクアクションを検出した場合、特定のアクションの確認をユーザーに求めます。詳細については設定タブを確認してください。", - "uk": "Агент знаходиться в режимі підтвердження. Він попросить користувача підтвердити певні дії, коли політика аналізатора безпеки виявить дію високого ризику. Перевірте вкладку налаштувань для отримання додаткової інформації." + "uk": "Агент знаходиться в режимі підтвердження. Він попросить користувача підтвердити певні дії, коли політика аналізатора безпеки виявить дію високого ризику. Перевірте вкладку налаштувань для отримання додаткової інформації.", + "ca": "L'agent es troba en mode de confirmació. Demanarà confirmació a l'usuari per a determinades accions quan l'analitzador de política de seguretat detecti una acció d'alt risc. Feu clic en aquesta icona per anar a la pestanya de configuració per obtenir més informació." }, "SETTINGS$AGENT_SELECT_ENABLED": { "en": "Enable Agent Selection - Advanced Users", @@ -6669,7 +7085,8 @@ "fr": "Activer la sélection d'agent - Utilisateurs avancés", "tr": "Ajan Seçimini Etkinleştir - İleri Düzey Kullanıcılar", "ja": "エージェント選択を有効化", - "uk": "Увімкнути вибір агента – Досвідчені користувачі" + "uk": "Увімкнути вибір агента – Досвідчені користувачі", + "ca": "Activa la selecció d'agent - Usuaris avançats" }, "SETTINGS$SECURITY_ANALYZER": { "en": "Enable Security Analyzer", @@ -6685,7 +7102,8 @@ "fr": "Activer l'analyseur de sécurité", "tr": "Güvenlik Analizörünü Etkinleştir", "ja": "セキュリティアナライザー", - "uk": "Увімкнути аналізатор безпеки" + "uk": "Увімкнути аналізатор безпеки", + "ca": "Activa l'analitzador de seguretat" }, "SETTINGS$SECURITY_ANALYZER_PLACEHOLDER": { "en": "Select a security analyzer…", @@ -6701,7 +7119,8 @@ "fr": "Sélectionnez un analyseur de sécurité…", "tr": "Bir güvenlik analizörü seçin…", "ja": "セキュリティアナライザーを選択…", - "uk": "Виберіть аналізатор безпеки…" + "uk": "Виберіть аналізатор безпеки…", + "ca": "Selecciona un analitzador de seguretat..." }, "SETTINGS$SECURITY_ANALYZER_TOOLTIP": { "en": "When enabled, the agent will pause and ask for confirmation when it tries to execute high-risk actions", @@ -6717,7 +7136,8 @@ "fr": "Lorsqu'il est activé, l'agent se mettra en pause et demandera confirmation lorsqu'il tentera d'exécuter des actions à haut risque", "tr": "Etkinleştirildiğinde, ajan yüksek riskli eylemleri gerçekleştirmeye çalıştığında duraklar ve onay ister", "ja": "有効にすると、エージェントは高リスクなアクションを実行しようとする際に一時停止し、確認を求めます", - "uk": "Коли увімкнено, агент зупиниться і попросить підтвердження, коли спробує виконати дії високого ризику" + "uk": "Коли увімкнено, агент зупиниться і попросить підтвердження, коли спробує виконати дії високого ризику", + "ca": "Quan estigui activat, l'agent farà una pausa i demanarà confirmació quan intenti executar accions d'alt risc" }, "SETTINGS$SECURITY_ANALYZER_DESCRIPTION": { "en": "The security analyzer will be used in conjunction with confirmation mode. By default, it utilizes LLM-predicted action risk to determine whether to prompt the user for confirmation. If the risk is HIGH, it will prompt the user for confirmation by default.", @@ -6733,7 +7153,8 @@ "fr": "L'analyseur de sécurité sera utilisé en conjonction avec le mode de confirmation. Par défaut, il utilise le risque d'action prédit par LLM pour déterminer s'il faut demander confirmation à l'utilisateur. Si le risque est ÉLEVÉ, il demandera confirmation par défaut.", "tr": "Güvenlik analizörü onay modu ile birlikte kullanılacaktır. Varsayılan olarak, kullanıcıdan onay istenip istenmeyeceğini belirlemek için LLM tarafından tahmin edilen eylem riskini kullanır. Risk YÜKSEK ise, varsayılan olarak kullanıcıdan onay isteyecektir.", "ja": "セキュリティアナライザーは確認モードと組み合わせて使用されます。デフォルトでは、LLMが予測したアクションリスクを利用して、ユーザーに確認を求めるかどうかを決定します。リスクが高い場合、デフォルトでユーザーに確認を求めます。", - "uk": "Аналізатор безпеки буде використовуватися разом з режимом підтвердження. За замовчуванням він використовує передбачений LLM ризик дії для визначення, чи потрібно запитувати підтвердження у користувача. Якщо ризик ВИСОКИЙ, він запитуватиме підтвердження за замовчуванням." + "uk": "Аналізатор безпеки буде використовуватися разом з режимом підтвердження. За замовчуванням він використовує передбачений LLM ризик дії для визначення, чи потрібно запитувати підтвердження у користувача. Якщо ризик ВИСОКИЙ, він запитуватиме підтвердження за замовчуванням.", + "ca": "L'analitzador de seguretat s'utilitzarà conjuntament amb el mode de confirmació. Per defecte, utilitza el risc d'acció predit per LLM per determinar si cal demanar confirmació a l'usuari. Si el risc és ALT, demanarà confirmació a l'usuari per defecte." }, "SETTINGS$DONT_KNOW_API_KEY": { "en": "Don't know your API key?", @@ -6749,7 +7170,8 @@ "fr": "Vous ne connaissez pas votre clé API ?", "tr": "API anahtarınızı bilmiyor musunuz?", "de": "Kennen Sie Ihren API-Schlüssel nicht?", - "uk": "Не знаєте свого API ключа?" + "uk": "Не знаєте свого API ключа?", + "ca": "No coneixeu la vostra clau d'API?" }, "SETTINGS$CLICK_FOR_INSTRUCTIONS": { "en": "Click here for instructions", @@ -6765,7 +7187,8 @@ "fr": "Cliquez ici pour les instructions", "tr": "Talimatlar için buraya tıklayın", "de": "Klicken Sie hier für Anweisungen", - "uk": "Натисніть тут, щоб отримати інструкції" + "uk": "Натисніть тут, щоб отримати інструкції", + "ca": "Feu clic aquí per obtenir instruccions" }, "SETTINGS$NEED_OPENHANDS_ACCOUNT": { "en": "Need an OpenHands Account?", @@ -6781,7 +7204,8 @@ "fr": "Besoin d'un compte OpenHands ?", "tr": "OpenHands hesabına mı ihtiyacınız var?", "de": "Benötigen Sie ein OpenHands-Konto?", - "uk": "Потрібен обліковий запис OpenHands?" + "uk": "Потрібен обліковий запис OpenHands?", + "ca": "Necessiteu un compte d'OpenHands?" }, "SETTINGS$CLICK_HERE": { "en": "Click here", @@ -6797,7 +7221,8 @@ "fr": "Cliquez ici", "tr": "Buraya tıklayın", "de": "Klicken Sie hier", - "uk": "Натисніть тут" + "uk": "Натисніть тут", + "ca": "Feu clic aquí" }, "SETTINGS$SAVED": { "en": "Settings saved", @@ -6813,7 +7238,8 @@ "fr": "Paramètres enregistrés", "tr": "Ayarlar kaydedildi", "de": "Einstellungen gespeichert", - "uk": "Налаштування збережено" + "uk": "Налаштування збережено", + "ca": "Configuració desada" }, "SETTINGS$SAVED_WARNING": { "en": "Settings saved. For old conversations, you will need to stop and restart the conversation to see the changes.", @@ -6829,7 +7255,8 @@ "fr": "Paramètres enregistrés. Pour les anciennes conversations, vous devrez arrêter et redémarrer la conversation pour voir les changements.", "tr": "Ayarlar kaydedildi. Eski konuşmalar için değişiklikleri görmek üzere konuşmayı durdurup yeniden başlatmanız gerekecek.", "de": "Einstellungen gespeichert. Für alte Gespräche müssen Sie das Gespräch stoppen und neu starten, um die Änderungen zu sehen.", - "uk": "Налаштування збережено. Для старих розмов вам потрібно буде зупинити та перезапустити розмову, щоб побачити зміни." + "uk": "Налаштування збережено. Для старих розмов вам потрібно буде зупинити та перезапустити розмову, щоб побачити зміни.", + "ca": "Configuració desada. Per a les converses antigues, haureu d'aturar i reiniciar la conversa per veure els canvis." }, "SETTINGS$RESET": { "en": "Settings reset", @@ -6845,7 +7272,8 @@ "fr": "Paramètres réinitialisés", "tr": "Ayarlar sıfırlandı", "de": "Einstellungen zurückgesetzt", - "uk": "Скидання налаштувань" + "uk": "Скидання налаштувань", + "ca": "Configuració restablerta" }, "SETTINGS$API_KEYS": { "en": "API Keys", @@ -6861,7 +7289,8 @@ "it": "Chiavi API", "pt": "Chaves API", "es": "Claves API", - "tr": "API Anahtarları" + "tr": "API Anahtarları", + "ca": "Claus d'API" }, "SETTINGS$API_KEYS_DESCRIPTION": { "en": "API keys allow you to authenticate with the OpenHands API programmatically. Keep your API keys secure; anyone with your API key can access your account. For more information on how to use the API, see our API documentation.", @@ -6877,7 +7306,8 @@ "fr": "Les clés API vous permettent de vous authentifier auprès de l'API OpenHands par programmation. Gardez vos clés API en sécurité ; toute personne disposant de votre clé API peut accéder à votre compte. Pour plus d'informations sur l'utilisation de l'API, consultez notre documentation API.", "tr": "API anahtarları, OpenHands API ile programlı olarak kimlik doğrulamanıza olanak tanır. API anahtarlarınızı güvende tutun; API anahtarınıza sahip olan herkes hesabınıza erişebilir. API'nin nasıl kullanılacağı hakkında daha fazla bilgi için API belgelerimize bakın.", "de": "API-Schlüssel ermöglichen es Ihnen, sich programmatisch bei der OpenHands-API zu authentifizieren. Halten Sie Ihre API-Schlüssel sicher; jeder mit Ihrem API-Schlüssel kann auf Ihr Konto zugreifen. Weitere Informationen zur Verwendung der API finden Sie in unserer API-Dokumentation.", - "uk": "Ключі API дозволяють вам програмно автентифікуватися за допомогою API OpenHands. Зберігайте свої ключі API в безпеці; будь-хто, хто має ваш ключ API, може отримати доступ до вашого облікового запису. Для отримання додаткової інформації про використання API, перегляньте нашу документацію API." + "uk": "Ключі API дозволяють вам програмно автентифікуватися за допомогою API OpenHands. Зберігайте свої ключі API в безпеці; будь-хто, хто має ваш ключ API, може отримати доступ до вашого облікового запису. Для отримання додаткової інформації про використання API, перегляньте нашу документацію API.", + "ca": "Les claus d'API us permeten autenticar-vos amb l'API d'OpenHands de manera programàtica. Manteniu les vostres claus d'API segures; qualsevol persona amb la vostra clau d'API pot accedir al vostre compte. Per obtenir més informació sobre com fer servir l'API, consulteu la nostra documentació de l'API." }, "SETTINGS$OPENHANDS_API_KEY_HELP": { "en": "You can find your OpenHands API Key in the API Keys tab of OpenHands Cloud.", @@ -6893,7 +7323,8 @@ "fr": "Vous pouvez trouver votre clé API OpenHands dans l'onglet Clés API d'OpenHands Cloud.", "tr": "OpenHands API Anahtarınızı OpenHands Cloud'un API Anahtarları sekmesinde bulabilirsiniz.", "de": "Sie finden Ihren OpenHands API-Schlüssel im Tab API-Schlüssel von OpenHands Cloud.", - "uk": "Ви можете знайти свій ключ API OpenHands у вкладці Ключі API OpenHands Cloud." + "uk": "Ви можете знайти свій ключ API OpenHands у вкладці Ключі API OpenHands Cloud.", + "ca": "Podeu trobar la vostra clau d'API d'OpenHands a la pestanya Claus d'API d'OpenHands Cloud." }, "SETTINGS$OPENHANDS_API_KEY_HELP_TEXT": { "en": "You can find your OpenHands API Key in the", @@ -6909,7 +7340,8 @@ "fr": "Vous pouvez trouver votre clé API OpenHands dans", "tr": "OpenHands API Anahtarınızı", "de": "Sie finden Ihren OpenHands API-Schlüssel im", - "uk": "Ви можете знайти свій ключ API OpenHands у" + "uk": "Ви можете знайти свій ключ API OpenHands у", + "ca": "Podeu trobar la vostra clau d'API d'OpenHands a la" }, "SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX": { "en": "tab of OpenHands Cloud.", @@ -6925,7 +7357,8 @@ "fr": "l'onglet d'OpenHands Cloud.", "tr": "OpenHands Cloud'un sekmesinde bulabilirsiniz.", "de": "Tab von OpenHands Cloud.", - "uk": "вкладці OpenHands Cloud." + "uk": "вкладці OpenHands Cloud.", + "ca": "pestanya d'OpenHands Cloud." }, "SETTINGS$LLM_BILLING_INFO": { "en": "LLM usage is billed at the providers' rates with no markup.", @@ -6941,7 +7374,8 @@ "fr": "L'utilisation de LLM est facturée aux tarifs des fournisseurs sans majoration.", "tr": "LLM kullanımı, sağlayıcıların oranlarında ek ücret olmadan faturalandırılır.", "de": "LLM-Nutzung wird zu Anbieterpreisen ohne Aufschlag abgerechnet.", - "uk": "Використання LLM оплачується за тарифами провайдерів без надбавки." + "uk": "Використання LLM оплачується за тарифами провайдерів без надбавки.", + "ca": "L'ús del LLM es factura a les tarifes dels proveïdors sense cap markup." }, "SETTINGS$LLM_ADMIN_INFO": { "en": "The LLM settings configured below will apply to all users of the organization.", @@ -6957,7 +7391,8 @@ "fr": "Les paramètres LLM configurés ci-dessous s'appliqueront à tous les utilisateurs de l'organisation.", "tr": "Aşağıda yapılandırılan LLM ayarları, kuruluştaki tüm kullanıcılara uygulanacaktır.", "de": "Die unten konfigurierten LLM-Einstellungen gelten für alle Benutzer der Organisation.", - "uk": "Налаштування LLM, налаштовані нижче, будуть застосовані до всіх користувачів організації." + "uk": "Налаштування LLM, налаштовані нижче, будуть застосовані до всіх користувачів організації.", + "ca": "La configuració LLM configurada a continuació s'aplicarà a tots els usuaris de l'organització." }, "SETTINGS$LLM_MEMBER_INFO": { "en": "LLM settings are managed by your organization's administrator.", @@ -6973,7 +7408,8 @@ "fr": "Les paramètres LLM sont gérés par l'administrateur de votre organisation.", "tr": "LLM ayarları kuruluşunuzun yöneticisi tarafından yönetilmektedir.", "de": "LLM-Einstellungen werden vom Administrator Ihrer Organisation verwaltet.", - "uk": "Налаштування LLM керуються адміністратором вашої організації." + "uk": "Налаштування LLM керуються адміністратором вашої організації.", + "ca": "La configuració LLM és gestionada per l'administrador de la vostra organització." }, "SETTINGS$SEE_PRICING_DETAILS": { "en": "See pricing details.", @@ -6989,7 +7425,8 @@ "fr": "Voir les détails de prix.", "tr": "Fiyat ayrıntılarını gör.", "de": "Preisdetails anzeigen.", - "uk": "Переглянути деталі цін." + "uk": "Переглянути деталі цін.", + "ca": "Consulteu els detalls dels preus." }, "SETTINGS$CREATE_API_KEY": { "en": "Create API Key", @@ -7005,7 +7442,8 @@ "it": "Crea chiave API", "pt": "Criar chave API", "es": "Crear clave API", - "tr": "API Anahtarı Oluştur" + "tr": "API Anahtarı Oluştur", + "ca": "Crea una clau d'API" }, "SETTINGS$CREATE_API_KEY_DESCRIPTION": { "en": "Give your API key a descriptive name to help you identify it later.", @@ -7021,7 +7459,8 @@ "it": "Dai alla tua chiave API un nome descrittivo per aiutarti a identificarla in seguito.", "pt": "Dê à sua chave API um nome descritivo para ajudá-lo a identificá-la posteriormente.", "es": "Asigne a su clave API un nombre descriptivo para ayudarle a identificarla más adelante.", - "tr": "API anahtarınıza, daha sonra tanımlamanıza yardımcı olacak açıklayıcı bir isim verin." + "tr": "API anahtarınıza, daha sonra tanımlamanıza yardımcı olacak açıklayıcı bir isim verin.", + "ca": "Doneu un nom descriptiu a la vostra clau d'API per poder-la identificar més endavant." }, "SETTINGS$DELETE_API_KEY": { "en": "Delete API Key", @@ -7037,7 +7476,8 @@ "it": "Elimina chiave API", "pt": "Excluir chave API", "es": "Eliminar clave API", - "tr": "API Anahtarını Sil" + "tr": "API Anahtarını Sil", + "ca": "Elimina la clau d'API" }, "SETTINGS$DELETE_API_KEY_CONFIRMATION": { "en": "Are you sure you want to delete the API key \"{{name}}\"? This action cannot be undone.", @@ -7053,7 +7493,8 @@ "it": "Sei sicuro di voler eliminare la chiave API \"{{name}}\"? Questa azione non può essere annullata.", "pt": "Tem certeza de que deseja excluir a chave API \"{{name}}\"? Esta ação não pode ser desfeita.", "es": "¿Está seguro de que desea eliminar la clave API \"{{name}}\"? Esta acción no se puede deshacer.", - "tr": "\"{{name}}\" API anahtarını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." + "tr": "\"{{name}}\" API anahtarını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "ca": "Esteu segur que voleu eliminar la clau d'API \"{{name}}\"? Aquesta acció no es pot desfer." }, "SETTINGS$NO_API_KEYS": { "en": "You don't have any API keys yet. Create one to get started.", @@ -7069,7 +7510,8 @@ "it": "Non hai ancora chiavi API. Creane una per iniziare.", "pt": "Você ainda não tem nenhuma chave API. Crie uma para começar.", "es": "Aún no tiene ninguna clave API. Cree una para comenzar.", - "tr": "Henüz API anahtarınız yok. Başlamak için bir tane oluşturun." + "tr": "Henüz API anahtarınız yok. Başlamak için bir tane oluşturun.", + "ca": "Encara no teniu cap clau d'API. Creeu-ne una per començar." }, "SETTINGS$NAME": { "en": "Name", @@ -7085,7 +7527,8 @@ "it": "Nome", "pt": "Nome", "es": "Nombre", - "tr": "İsim" + "tr": "İsim", + "ca": "Nom" }, "SECRETS$DESCRIPTION": { "en": "Description", @@ -7101,7 +7544,8 @@ "it": "Descrizione", "pt": "Descrição", "es": "Descripción", - "tr": "Açıklama" + "tr": "Açıklama", + "ca": "Descripció" }, "SETTINGS$KEY_PREFIX": { "en": "Key Prefix", @@ -7117,7 +7561,8 @@ "it": "Prefisso chiave", "pt": "Prefixo da chave", "es": "Prefijo de clave", - "tr": "Anahtar Öneki" + "tr": "Anahtar Öneki", + "ca": "Prefix de la clau" }, "SETTINGS$CREATED_AT": { "en": "Created", @@ -7133,7 +7578,8 @@ "it": "Creato", "pt": "Criado", "es": "Creado", - "tr": "Oluşturuldu" + "tr": "Oluşturuldu", + "ca": "Creat" }, "SETTINGS$LAST_USED": { "en": "Last Used", @@ -7149,7 +7595,8 @@ "it": "Ultimo utilizzo", "pt": "Último uso", "es": "Último uso", - "tr": "Son Kullanım" + "tr": "Son Kullanım", + "ca": "Últim ús" }, "SETTINGS$ACTIONS": { "en": "Actions", @@ -7165,7 +7612,8 @@ "it": "Azioni", "pt": "Ações", "es": "Acciones", - "tr": "İşlemler" + "tr": "İşlemler", + "ca": "Accions" }, "SETTINGS$API_KEY_CREATED": { "en": "API Key Created", @@ -7181,7 +7629,8 @@ "it": "Chiave API creata", "pt": "Chave API criada", "es": "Clave API creada", - "tr": "API Anahtarı Oluşturuldu" + "tr": "API Anahtarı Oluşturuldu", + "ca": "Clau d'API creada" }, "SETTINGS$API_KEY_DELETED": { "en": "API key deleted successfully", @@ -7197,7 +7646,8 @@ "it": "Chiave API eliminata con successo", "pt": "Chave API excluída com sucesso", "es": "Clave API eliminada con éxito", - "tr": "API anahtarı başarıyla silindi" + "tr": "API anahtarı başarıyla silindi", + "ca": "La clau d'API s'ha eliminat correctament" }, "SETTINGS$API_KEY_WARNING": { "en": "This is the only time your API key will be displayed. Please copy it now and store it securely.", @@ -7213,7 +7663,8 @@ "it": "Questa è l'unica volta che la tua chiave API verrà visualizzata. Copiala ora e conservala in modo sicuro.", "pt": "Esta é a única vez que sua chave API será exibida. Por favor, copie-a agora e armazene-a com segurança.", "es": "Esta es la única vez que se mostrará su clave API. Por favor, cópiela ahora y guárdela de forma segura.", - "tr": "API anahtarınız yalnızca bu kez görüntülenecektir. Lütfen şimdi kopyalayın ve güvenli bir şekilde saklayın." + "tr": "API anahtarınız yalnızca bu kez görüntülenecektir. Lütfen şimdi kopyalayın ve güvenli bir şekilde saklayın.", + "ca": "Aquesta és l'única vegada que es mostrarà la vostra clau d'API. Copieu-la ara i guardeu-la en un lloc segur." }, "SETTINGS$API_KEY_COPIED": { "en": "API key copied to clipboard", @@ -7229,7 +7680,8 @@ "it": "Chiave API copiata negli appunti", "pt": "Chave API copiada para a área de transferência", "es": "Clave API copiada al portapapeles", - "tr": "API anahtarı panoya kopyalandı" + "tr": "API anahtarı panoya kopyalandı", + "ca": "Clau d'API copiada al porta-retalls" }, "SETTINGS$API_KEY_REFRESHED": { "en": "API key refreshed successfully", @@ -7245,7 +7697,8 @@ "it": "Chiave API aggiornata con successo", "pt": "Chave API atualizada com sucesso", "es": "Clave API actualizada con éxito", - "tr": "API anahtarı başarıyla yenilendi" + "tr": "API anahtarı başarıyla yenilendi", + "ca": "La clau d'API s'ha actualitzat correctament" }, "SETTINGS$API_KEY_NAME_PLACEHOLDER": { "en": "My API Key", @@ -7261,7 +7714,8 @@ "it": "La mia chiave API", "pt": "Minha chave API", "es": "Mi clave API", - "tr": "API Anahtarım" + "tr": "API Anahtarım", + "ca": "La meva clau d'API" }, "BUTTON$CREATE": { "en": "Create", @@ -7277,7 +7731,8 @@ "it": "Crea", "pt": "Criar", "es": "Crear", - "tr": "Oluştur" + "tr": "Oluştur", + "ca": "Crea" }, "BUTTON$DELETE": { "en": "Delete", @@ -7293,7 +7748,8 @@ "it": "Elimina", "pt": "Excluir", "es": "Eliminar", - "tr": "Sil" + "tr": "Sil", + "ca": "Elimina" }, "BUTTON$COPY_TO_CLIPBOARD": { "en": "Copy to Clipboard", @@ -7309,7 +7765,8 @@ "it": "Copia negli appunti", "pt": "Copiar para a área de transferência", "es": "Copiar al portapapeles", - "tr": "Panoya Kopyala" + "tr": "Panoya Kopyala", + "ca": "Copia al porta-retalls" }, "BUTTON$HOME": { "en": "Home", @@ -7325,7 +7782,8 @@ "fr": "Accueil", "tr": "Ana Sayfa", "de": "Startseite", - "uk": "Головна" + "uk": "Головна", + "ca": "Inici" }, "BUTTON$OPEN_IN_NEW_TAB": { "en": "Open in New Tab", @@ -7341,7 +7799,8 @@ "fr": "Ouvrir dans un nouvel onglet", "tr": "Yeni Sekmede Aç", "de": "In neuem Tab öffnen", - "uk": "Відкрити в новій вкладці" + "uk": "Відкрити в новій вкладці", + "ca": "Obre en una pestanya nova" }, "BUTTON$REFRESH": { "en": "Refresh", @@ -7357,7 +7816,8 @@ "fr": "Rafraîchir", "tr": "Yenile", "de": "Aktualisieren", - "uk": "Оновити" + "uk": "Оновити", + "ca": "Actualitza" }, "ERROR$REQUIRED_FIELD": { "en": "This field is required", @@ -7373,7 +7833,8 @@ "it": "Questo campo è obbligatorio", "pt": "Este campo é obrigatório", "es": "Este campo es obligatorio", - "tr": "Bu alan zorunludur" + "tr": "Bu alan zorunludur", + "ca": "Aquest camp és obligatori" }, "PLANNER$EMPTY_MESSAGE": { "en": "There is currently no plan for this repo", @@ -7389,7 +7850,8 @@ "it": "Attualmente non c'è un piano per questo repository", "pt": "Atualmente não há plano para este repositório", "es": "Actualmente no hay un plan para este repositorio", - "tr": "Şu anda bu depo için bir plan yok" + "tr": "Şu anda bu depo için bir plan yok", + "ca": "Actualment no hi ha cap pla per a aquest repositori" }, "SIDEBAR$NAVIGATION_LABEL": { "en": "Sidebar navigation", @@ -7405,7 +7867,8 @@ "fr": "Navigation de la barre latérale", "tr": "Kenar çubuğu gezintisi", "ja": "サイドバーのナビゲーション", - "uk": "Навігація бічної панелі" + "uk": "Навігація бічної панелі", + "ca": "Navegació de la barra lateral" }, "FEEDBACK$PUBLIC_LABEL": { "en": "Public", @@ -7421,7 +7884,8 @@ "fr": "Public", "tr": "Herkese Açık", "ja": "公開", - "uk": "Загальнодоступний" + "uk": "Загальнодоступний", + "ca": "Públic" }, "FEEDBACK$PRIVATE_LABEL": { "en": "Private", @@ -7437,7 +7901,8 @@ "fr": "Privé", "tr": "Özel", "ja": "非公開", - "uk": "Приватний" + "uk": "Приватний", + "ca": "Privat" }, "SIDEBAR$CONVERSATIONS": { "en": "Conversations", @@ -7453,7 +7918,8 @@ "ar": "المحادثات", "fr": "Conversations", "tr": "Konuşmalar", - "uk": "Розмови" + "uk": "Розмови", + "ca": "Converses" }, "STATUS$CONNECTING_TO_RUNTIME": { "en": "Connecting to runtime...", @@ -7469,7 +7935,8 @@ "fr": "Connexion à l'environnement d'exécution en cours...", "tr": "Çalışma zamanı ortamına bağlanılıyor...", "ja": "ランタイムに接続中", - "uk": "Підключення до середовища виконання..." + "uk": "Підключення до середовища виконання...", + "ca": "Connectant a l'entorn d'execució..." }, "STATUS$STARTING_RUNTIME": { "en": "Starting runtime... (this may take 1-2 minutes)", @@ -7485,7 +7952,8 @@ "fr": "Démarrage de l'environnement d'exécution... (cela peut prendre 1-2 minutes)", "tr": "Çalışma zamanı başlatılıyor... (bu 1-2 dakika sürebilir)", "ja": "ランタイムを開始中...(1-2分かかる場合があります)", - "uk": "Запуск середовища виконання... (це може зайняти 1-2 хвилини)" + "uk": "Запуск середовища виконання... (це може зайняти 1-2 хвилини)", + "ca": "Iniciant l'entorn d'execució... (això pot trigar 1-2 minuts)" }, "STATUS$SETTING_UP_WORKSPACE": { "en": "Setting up workspace...", @@ -7501,7 +7969,8 @@ "fr": "Configuration de l'espace de travail...", "tr": "Çalışma alanı ayarlanıyor...", "ja": "ワークスペースを設定中...", - "uk": "Налаштування робочого простору..." + "uk": "Налаштування робочого простору...", + "ca": "Configurant l'espai de treball..." }, "STATUS$SETTING_UP_GIT_HOOKS": { "en": "Setting up git hooks...", @@ -7517,7 +7986,8 @@ "fr": "Configuration des hooks git...", "tr": "Git kancaları ayarlanıyor...", "ja": "git フックを設定中...", - "uk": "Налаштування git-хуків..." + "uk": "Налаштування git-хуків...", + "ca": "Configurant els hooks de Git..." }, "STATUS$SETTING_UP_SKILLS": { "en": "Setting up skills...", @@ -7533,7 +8003,8 @@ "fr": "Configuration des compétences...", "tr": "Yetenekler ayarlanıyor...", "ja": "スキルを設定中...", - "uk": "Налаштування навичок..." + "uk": "Налаштування навичок...", + "ca": "Configurant les habilitats..." }, "ACCOUNT_SETTINGS_MODAL$DISCONNECT": { "en": "Disconnect", @@ -7549,7 +8020,8 @@ "it": "Disconnetti", "pt": "Desconectar", "tr": "Bağlantıyı kes", - "uk": "Відключитися" + "uk": "Відключитися", + "ca": "Desconnecta" }, "ACCOUNT_SETTINGS_MODAL$SAVE": { "en": "Save", @@ -7565,7 +8037,8 @@ "it": "Salva", "pt": "Salvar", "tr": "Kaydet", - "uk": "Зберегти" + "uk": "Зберегти", + "ca": "Desa" }, "ACCOUNT_SETTINGS_MODAL$CLOSE": { "en": "Close", @@ -7581,7 +8054,8 @@ "it": "Chiudi", "pt": "Fechar", "tr": "Kapat", - "uk": "Закрити" + "uk": "Закрити", + "ca": "Tanca" }, "ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID": { "en": "GitHub token is invalid. Please try again.", @@ -7597,7 +8071,8 @@ "it": "Il token GitHub non è valido. Per favore riprova.", "pt": "O token do GitHub é inválido. Por favor, tente novamente.", "tr": "GitHub token'ı geçersiz. Lütfen tekrar deneyin.", - "uk": "Токен GitHub недійсний. Спробуйте ще раз." + "uk": "Токен GitHub недійсний. Спробуйте ще раз.", + "ca": "El token de GitHub no és vàlid. Torneu-ho a intentar." }, "CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN": { "en": "Get your token", @@ -7613,7 +8088,8 @@ "it": "Ottieni il tuo token", "pt": "Obtenha seu token", "tr": "Token'ınızı alın", - "uk": "Отримайте свій токен" + "uk": "Отримайте свій токен", + "ca": "Obteniu el vostre token" }, "CONNECT_TO_GITHUB_MODAL$HERE": { "en": "here", @@ -7629,7 +8105,8 @@ "it": "qui", "pt": "aqui", "tr": "burada", - "uk": "тут" + "uk": "тут", + "ca": "aquí" }, "CONNECT_TO_GITHUB_MODAL$CONNECT": { "en": "Connect", @@ -7645,7 +8122,8 @@ "tr": "Bağlan", "ko-KR": "연결", "ja": "接続", - "uk": "Підключитися" + "uk": "Підключитися", + "ca": "Connecta" }, "CONNECT_TO_GITHUB_MODAL$CLOSE": { "en": "Close", @@ -7661,7 +8139,8 @@ "it": "Chiudi", "pt": "Fechar", "tr": "Kapat", - "uk": "Закрити" + "uk": "Закрити", + "ca": "Tanca" }, "CONNECT_TO_GITHUB_BY_TOKEN_MODAL$BY_CONNECTING_YOU_AGREE": { "en": "By connecting you agree to our", @@ -7677,7 +8156,8 @@ "it": "Connettendoti accetti i nostri", "pt": "Ao conectar você concorda com nossos", "tr": "Bağlanarak kabul etmiş olursunuz", - "uk": "Підключаючись, ви погоджуєтеся з нашими" + "uk": "Підключаючись, ви погоджуєтеся з нашими", + "ca": "En connectar-vos, accepteu les nostres" }, "CONNECT_TO_GITHUB_BY_TOKEN_MODAL$CONTINUE": { "en": "Continue", @@ -7693,7 +8173,8 @@ "it": "Continua", "pt": "Continuar", "tr": "Devam et", - "uk": "Продовжити" + "uk": "Продовжити", + "ca": "Continua" }, "LOADING_PROJECT$LOADING": { "en": "Loading...", @@ -7709,7 +8190,8 @@ "it": "Caricamento...", "pt": "Carregando...", "tr": "Yükleniyor...", - "uk": "Завантаження..." + "uk": "Завантаження...", + "ca": "Carregant..." }, "CUSTOM_INPUT$OPTIONAL_LABEL": { "en": "(Optional)", @@ -7725,7 +8207,8 @@ "it": "(Opzionale)", "pt": "(Opcional)", "tr": "(İsteğe bağlı)", - "uk": "(Необов'язково)" + "uk": "(Необов'язково)", + "ca": "(Opcional)" }, "SETTINGS_FORM$CUSTOM_MODEL_LABEL": { "en": "Custom Model", @@ -7741,7 +8224,8 @@ "it": "Modello personalizzato", "pt": "Modelo personalizado", "tr": "Özel model", - "uk": "Власна модель" + "uk": "Власна модель", + "ca": "Model personalitzat" }, "SETTINGS_FORM$BASE_URL_LABEL": { "en": "Base URL", @@ -7757,7 +8241,8 @@ "it": "URL di base", "pt": "URL base", "tr": "Temel URL", - "uk": "Базовий URL" + "uk": "Базовий URL", + "ca": "URL base" }, "SETTINGS_FORM$API_KEY_LABEL": { "en": "API Key", @@ -7773,7 +8258,8 @@ "it": "Chiave API", "pt": "Chave API", "tr": "API Anahtarı", - "uk": "Ключ API" + "uk": "Ключ API", + "ca": "Clau d'API" }, "SETTINGS_FORM$DONT_KNOW_API_KEY_LABEL": { "en": "Don't know your API key?", @@ -7789,7 +8275,8 @@ "it": "Non conosci la tua chiave API?", "pt": "Não sabe sua chave API?", "tr": "API anahtarınızı bilmiyor musunuz?", - "uk": "Не знаєте свого API-ключа?" + "uk": "Не знаєте свого API-ключа?", + "ca": "No coneixeu la vostra clau d'API?" }, "SETTINGS_FORM$CLICK_HERE_FOR_INSTRUCTIONS_LABEL": { "en": "Click here for instructions", @@ -7805,7 +8292,8 @@ "it": "Clicca qui per le istruzioni", "pt": "Clique aqui para instruções", "tr": "Talimatlar için buraya tıklayın", - "uk": "Натисніть тут, щоб отримати інструкції" + "uk": "Натисніть тут, щоб отримати інструкції", + "ca": "Feu clic aquí per obtenir instruccions" }, "SETTINGS_FORM$AGENT_LABEL": { "en": "Agent", @@ -7821,7 +8309,8 @@ "it": "Agente", "pt": "Agente", "tr": "Ajan", - "uk": "Агент" + "uk": "Агент", + "ca": "Agent" }, "SETTINGS_FORM$SECURITY_ANALYZER_LABEL": { "en": "Security Analyzer (Optional)", @@ -7837,7 +8326,8 @@ "it": "Analizzatore di sicurezza (Opzionale)", "pt": "Analisador de segurança (Opcional)", "tr": "Güvenlik Analizörü (İsteğe bağlı)", - "uk": "Аналізатор безпеки (необов'язково)" + "uk": "Аналізатор безпеки (необов'язково)", + "ca": "Analitzador de seguretat (opcional)" }, "SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL": { "en": "Enable Confirmation Mode", @@ -7853,7 +8343,8 @@ "it": "Abilita modalità di conferma", "pt": "Ativar modo de confirmação", "tr": "Onay modunu etkinleştir", - "uk": "Увімкнути режим підтвердження" + "uk": "Увімкнути режим підтвердження", + "ca": "Activa el mode de confirmació" }, "SETTINGS_FORM$SAVE_LABEL": { "en": "Save", @@ -7869,7 +8360,8 @@ "it": "Salva", "pt": "Salvar", "tr": "Kaydet", - "uk": "Зберегти" + "uk": "Зберегти", + "ca": "Desa" }, "SETTINGS_FORM$CLOSE_LABEL": { "en": "Close", @@ -7885,7 +8377,8 @@ "it": "Chiudi", "pt": "Fechar", "tr": "Kapat", - "uk": "Закрити" + "uk": "Закрити", + "ca": "Tanca" }, "SETTINGS_FORM$CANCEL_LABEL": { "en": "Cancel", @@ -7901,7 +8394,8 @@ "it": "Annulla", "pt": "Cancelar", "tr": "İptal", - "uk": "Скасувати" + "uk": "Скасувати", + "ca": "Cancel·la" }, "SETTINGS_FORM$END_SESSION_LABEL": { "en": "End Session", @@ -7917,7 +8411,8 @@ "it": "Termina sessione", "pt": "Encerrar sessão", "tr": "Oturumu sonlandır", - "uk": "Закінчити сеанс" + "uk": "Закінчити сеанс", + "ca": "Finalitza la sessió" }, "SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE": { "en": "Changing your settings will clear your workspace and start a new session. Are you sure you want to continue?", @@ -7933,7 +8428,8 @@ "it": "La modifica delle impostazioni cancellerà il tuo spazio di lavoro e avvierà una nuova sessione. Sei sicuro di voler continuare?", "pt": "Alterar suas configurações limpará seu espaço de trabalho e iniciará uma nova sessão. Tem certeza de que deseja continuar?", "tr": "Ayarlarınızı değiştirmek çalışma alanınızı temizleyecek ve yeni bir oturum başlatacak. Devam etmek istediğinizden emin misiniz?", - "uk": "Зміна налаштувань очистить вашу робочу область та розпочне новий сеанс. Ви впевнені, що хочете продовжити?" + "uk": "Зміна налаштувань очистить вашу робочу область та розпочне новий сеанс. Ви впевнені, що хочете продовжити?", + "ca": "Canviar la configuració buidarà l'espai de treball i iniciarà una nova sessió. Esteu segur que voleu continuar?" }, "SETTINGS_FORM$ARE_YOU_SURE_LABEL": { "en": "Are you sure?", @@ -7949,7 +8445,8 @@ "it": "Sei sicuro?", "pt": "Tem certeza?", "tr": "Emin misiniz?", - "uk": "Ви впевнені?" + "uk": "Ви впевнені?", + "ca": "Esteu segur?" }, "SETTINGS_FORM$ALL_INFORMATION_WILL_BE_DELETED_MESSAGE": { "en": "All saved information in your AI settings will be deleted, including any API keys.", @@ -7965,7 +8462,8 @@ "it": "Tutte le informazioni salvate nelle tue impostazioni AI saranno eliminate, incluse le chiavi API.", "pt": "Todas as informações salvas nas suas configurações de IA serão excluídas, incluindo quaisquer chaves API.", "tr": "API anahtarları dahil olmak üzere AI ayarlarınızdaki tüm kayıtlı bilgiler silinecektir.", - "uk": "Усю збережену інформацію у ваших налаштуваннях штучного інтелекту буде видалено, включаючи будь-які ключі API." + "uk": "Усю збережену інформацію у ваших налаштуваннях штучного інтелекту буде видалено, включаючи будь-які ключі API.", + "ca": "Tota la informació desada a la configuració de la IA s'eliminarà, incloses les claus d'API." }, "PROJECT_MENU_DETAILS_PLACEHOLDER$NEW_PROJECT_LABEL": { "en": "New Project", @@ -7981,7 +8479,8 @@ "it": "Nuovo progetto", "pt": "Novo projeto", "tr": "Yeni proje", - "uk": "Новий проект" + "uk": "Новий проект", + "ca": "Nou projecte" }, "PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB": { "en": "Connect to GitHub", @@ -7997,7 +8496,8 @@ "pt": "Conectar ao GitHub", "es": "Conectar a GitHub", "tr": "GitHub'a bağlan", - "uk": "Підключитися до GitHub" + "uk": "Підключитися до GitHub", + "ca": "Connecta a GitHub" }, "PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED": { "en": "Connected", @@ -8013,7 +8513,8 @@ "pt": "Conectado", "es": "Conectado", "tr": "Bağlandı", - "uk": "Підключено" + "uk": "Підключено", + "ca": "Connectat" }, "PROJECT_MENU_DETAILS$AGO_LABEL": { "en": "ago", @@ -8029,7 +8530,8 @@ "it": "fa", "pt": "atrás", "tr": "önce", - "uk": "тому" + "uk": "тому", + "ca": "fa" }, "STATUS$ERROR_LLM_AUTHENTICATION": { "en": "Error authenticating with the LLM provider. Please check your API key", @@ -8045,7 +8547,8 @@ "it": "Errore di autenticazione con il provider LLM. Controlla la tua chiave API", "pt": "Erro ao autenticar com o provedor LLM. Por favor, verifique sua chave API", "tr": "LLM sağlayıcısı ile kimlik doğrulama hatası. Lütfen API anahtarınızı kontrol edin", - "uk": "Помилка автентифікації у постачальника LLM. Перевірте свій ключ API." + "uk": "Помилка автентифікації у постачальника LLM. Перевірте свій ключ API.", + "ca": "Error d'autenticació amb el proveïdor de LLM. Comproveu la vostra clau d'API" }, "STATUS$ERROR_LLM_SERVICE_UNAVAILABLE": { "en": "The LLM provider is currently unavailable. Please try again later.", @@ -8061,7 +8564,8 @@ "it": "Il provider LLM non è attualmente disponibile. Per favore, riprova più tardi.", "pt": "O provedor LLM não está atualmente disponível. Por favor, tente novamente mais tarde.", "tr": "LLM sağlayıcısı şu anda kullanılamıyor. Lütfen daha sonra tekrar deneyin.", - "uk": "Постачальник LLM наразі недоступний. Будь ласка, спробуйте пізніше." + "uk": "Постачальник LLM наразі недоступний. Будь ласка, спробуйте пізніше.", + "ca": "El proveïdor de LLM no està disponible actualment. Torneu-ho a intentar més tard." }, "STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR": { "en": "The request failed with an internal server error.", @@ -8077,7 +8581,8 @@ "it": "Si è verificato un errore durante la connessione al runtime. Aggiorna la pagina.", "pt": "Ocorreu um erro ao conectar ao ambiente de execução. Por favor, atualize a página.", "tr": "Çalışma zamanına bağlanırken bir hata oluştu. Lütfen sayfayı yenileyin.", - "uk": "Запит не вдалося виконати через внутрішню помилку сервера." + "uk": "Запит не вдалося виконати через внутрішню помилку сервера.", + "ca": "La sol·licitud ha fallat amb un error intern del servidor." }, "STATUS$ERROR_LLM_OUT_OF_CREDITS": { "en": "You're out of OpenHands Credits. Add funds", @@ -8093,7 +8598,8 @@ "fr": "Vous n'avez plus de crédits OpenHands. Ajouter des fonds", "tr": "OpenHands kredileriniz tükendi. Bakiye ekle", "de": "Ihre OpenHands-Guthaben sind aufgebraucht. Guthaben hinzufügen", - "uk": "У вас закінчилися кредити OpenHands. Додати кошти" + "uk": "У вас закінчилися кредити OpenHands. Додати кошти", + "ca": "No teniu crèdits d'OpenHands. Afegiu fons" }, "STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION": { "en": "Content policy violation. The output was blocked by content filtering policy.", @@ -8109,7 +8615,8 @@ "ar": "انتهاك سياسة المحتوى. تم حظر المخرجات بواسطة سياسة تصفية المحتوى.", "fr": "Violation de la politique de contenu. La sortie a été bloquée par la politique de filtrage de contenu.", "tr": "İçerik politikası ihlali. Çıktı, içerik filtreleme politikası tarafından engellendi.", - "uk": "Порушення політики щодо вмісту. Вивід було заблоковано політикою фільтрації вмісту." + "uk": "Порушення політики щодо вмісту. Вивід було заблоковано політикою фільтрації вмісту.", + "ca": "Infracció de la política de contingut. La sortida ha estat bloquejada per la política de filtratge de contingut." }, "STATUS$ERROR": { "en": "An error occurred. Please try again.", @@ -8125,7 +8632,8 @@ "pt": "Ocorreu um erro. Por favor, tente novamente.", "es": "Ocurrió un error. Por favor, inténtalo de nuevo.", "tr": "Bir hata oluştu. Lütfen tekrar deneyin.", - "uk": "Сталася помилка. Будь ласка, спробуйте ще раз." + "uk": "Сталася помилка. Будь ласка, спробуйте ще раз.", + "ca": "S'ha produït un error. Torneu-ho a intentar." }, "STATUS$ERROR_RUNTIME_DISCONNECTED": { "en": "There was an error while connecting to the runtime. Please refresh the page.", @@ -8141,7 +8649,8 @@ "pt": "Ocorreu um erro ao conectar ao ambiente de execução. Por favor, atualize a página.", "es": "Hubo un error al conectar con el entorno de ejecución. Por favor, actualice la página.", "tr": "Çalışma zamanına bağlanırken bir hata oluştu. Lütfen sayfayı yenileyin.", - "uk": "Під час підключення до середовища виконання сталася помилка. Оновіть сторінку." + "uk": "Під час підключення до середовища виконання сталася помилка. Оновіть сторінку.", + "ca": "S'ha produït un error en connectar a l'entorn d'execució. Actualitzeu la pàgina." }, "STATUS$ERROR_MEMORY": { "en": "Memory error occurred. Please try reducing the workload or restarting.", @@ -8157,7 +8666,8 @@ "pt": "Ocorreu um erro de memória. Tente reduzir a carga de trabalho ou reiniciar.", "es": "Ocurrió un error de memoria. Intenta reducir la carga de trabajo o reiniciar.", "tr": "Bellek hatası oluştu. Lütfen iş yükünü azaltmayı veya yeniden başlatmayı deneyin.", - "uk": "Сталася помилка пам'яті. Спробуйте зменшити навантаження або перезапустити." + "uk": "Сталася помилка пам'яті. Спробуйте зменшити навантаження або перезапустити.", + "ca": "S'ha produït un error de memòria. Intenteu reduir la càrrega de treball o reiniciar." }, "STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR": { "en": "Error authenticating with the Git provider. Please check your credentials.", @@ -8173,7 +8683,8 @@ "pt": "Erro ao autenticar com o provedor Git. Por favor, verifique suas credenciais.", "es": "Error al autenticar con el proveedor Git. Por favor, verifica tus credenciales.", "tr": "Git sağlayıcısı ile kimlik doğrulama hatası. Lütfen kimlik bilgilerinizi kontrol edin.", - "uk": "Помилка автентифікації у постачальника Git. Перевірте свої облікові дані." + "uk": "Помилка автентифікації у постачальника Git. Перевірте свої облікові дані.", + "ca": "Error d'autenticació amb el proveïdor de Git. Comproveu les vostres credencials." }, "STATUS$LLM_RETRY": { "en": "Retrying LLM request", @@ -8189,7 +8700,8 @@ "it": "Ritenta la richiesta LLM", "pt": "Reintentando a solicitação LLM", "tr": "LLM isteğini yeniden deniyor", - "uk": "Повторна спроба запиту LLM" + "uk": "Повторна спроба запиту LLM", + "ca": "Reintentant la sol·licitud al LLM" }, "AGENT_ERROR$BAD_ACTION": { "en": "Agent tried to execute a malformed action.", @@ -8205,7 +8717,8 @@ "pt": "O agente tentou executar uma ação malformada", "es": "El agente intentó ejecutar una acción malformada", "tr": "Ajan hatalı bir eylem gerçekleştirmeye çalıştı", - "uk": "Агент спробував виконати невірну дію." + "uk": "Агент спробував виконати невірну дію.", + "ca": "L'agent ha intentat executar una acció mal formada." }, "AGENT_ERROR$ACTION_TIMEOUT": { "en": "Action timed out.", @@ -8221,7 +8734,8 @@ "pt": "Ação expirou", "es": "La acción expiró", "tr": "İşlem zaman aşımına uğradı", - "uk": "Час дії вичерпано." + "uk": "Час дії вичерпано.", + "ca": "L'acció ha superat el temps d'espera." }, "AGENT_ERROR$TOO_MANY_CONVERSATIONS": { "en": "Too many conversations at once.", @@ -8237,7 +8751,8 @@ "it": "Troppe conversazioni contemporaneamente.", "pt": "Muitas conversas ao mesmo tempo.", "es": "Demasiadas conversaciones a la vez.", - "tr": "Aynı anda çok fazla konuşma var." + "tr": "Aynı anda çok fazla konuşma var.", + "ca": "Hi ha massa converses alhora." }, "PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL": { "en": "Connect to GitHub", @@ -8253,7 +8768,8 @@ "it": "Connetti a GitHub", "pt": "Conectar ao GitHub", "tr": "GitHub'a bağlan", - "uk": "Підключитися до GitHub" + "uk": "Підключитися до GitHub", + "ca": "Connecta a GitHub" }, "PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL": { "en": "Push to GitHub", @@ -8269,7 +8785,8 @@ "it": "Invia a GitHub", "pt": "Enviar para GitHub", "tr": "GitHub'a gönder", - "uk": "Надіслати на GitHub" + "uk": "Надіслати на GitHub", + "ca": "Publica a GitHub" }, "PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL": { "en": "Download files", @@ -8285,7 +8802,8 @@ "it": "Scarica file", "pt": "Baixar arquivos", "tr": "Dosyaları indir", - "uk": "Завантаження файлів" + "uk": "Завантаження файлів", + "ca": "Descarrega els fitxers" }, "PROJECT_MENU_CARD$OPEN": { "en": "Open project menu", @@ -8301,7 +8819,8 @@ "pt": "Abrir menu do projeto", "es": "Abrir menú del proyecto", "tr": "Proje menüsünü aç", - "uk": "Відкрити меню проекту" + "uk": "Відкрити меню проекту", + "ca": "Obre el menú del projecte" }, "ACTION_BUTTON$RESUME": { "en": "Resume the agent task", @@ -8317,7 +8836,8 @@ "pt": "Retomar a tarefa do agente", "es": "Reanudar la tarea del agente", "tr": "Ajan görevine devam et", - "uk": "Відновити завдання агента" + "uk": "Відновити завдання агента", + "ca": "Reprèn la tasca de l'agent" }, "BROWSER$SCREENSHOT_ALT": { "en": "Browser Screenshot", @@ -8333,7 +8853,8 @@ "pt": "Captura de tela do navegador", "es": "Captura de pantalla del navegador", "tr": "Tarayıcı ekran görüntüsü", - "uk": "Знімок екрана браузера" + "uk": "Знімок екрана браузера", + "ca": "Captura de pantalla del navegador" }, "ERROR_TOAST$CLOSE_BUTTON_LABEL": { "en": "Close", @@ -8349,7 +8870,8 @@ "pt": "Fechar", "es": "Cerrar", "tr": "Kapat", - "uk": "Закрити" + "uk": "Закрити", + "ca": "Tanca" }, "FILE_EXPLORER$UPLOAD": { "en": "Upload File", @@ -8365,7 +8887,8 @@ "pt": "Enviar arquivo", "es": "Subir archivo", "tr": "Dosya yükle", - "uk": "Завантажити файл" + "uk": "Завантажити файл", + "ca": "Carrega un fitxer" }, "ACTION_MESSAGE$RUN": { "en": "Running {{command}}", @@ -8381,7 +8904,8 @@ "pt": "Executando {{command}}", "es": "Ejecutando {{command}}", "tr": "{{command}} çalıştırılıyor", - "uk": "Виконую {{command}}" + "uk": "Виконую {{command}}", + "ca": "S'executa {{command}}" }, "ACTION_MESSAGE$RUN_IPYTHON": { "en": "Running a Python command", @@ -8397,7 +8921,8 @@ "pt": "Executando um comando Python", "es": "Ejecutando un comando Python", "tr": "Python komutu çalıştırılıyor", - "uk": "Виконання команди Python" + "uk": "Виконання команди Python", + "ca": "S'executa una comanda Python" }, "ACTION_MESSAGE$CALL_TOOL_MCP": { "en": "Calling MCP Tool: {{mcp_tool_name}}", @@ -8413,7 +8938,8 @@ "pt": "Chamando ferramenta MCP: {{mcp_tool_name}}", "es": "Llamando a la herramienta MCP: {{mcp_tool_name}}", "tr": "MCP Aracı çağrılıyor: {{mcp_tool_name}}", - "uk": "Викликаю інструмент MCP: {{mcp_tool_name}}" + "uk": "Викликаю інструмент MCP: {{mcp_tool_name}}", + "ca": "S'invoca l'eina MCP: {{mcp_tool_name}}" }, "ACTION_MESSAGE$READ": { "en": "Reading {{path}}", @@ -8429,7 +8955,8 @@ "pt": "Lendo {{path}}", "es": "Leyendo {{path}}", "tr": "{{path}} okunuyor", - "uk": "Читаю {{path}}" + "uk": "Читаю {{path}}", + "ca": "S'llegeix {{path}}" }, "ACTION_MESSAGE$EDIT": { "en": "Editing {{path}}", @@ -8445,7 +8972,8 @@ "pt": "Editando {{path}}", "es": "Editando {{path}}", "tr": "{{path}} düzenleniyor", - "uk": "Редагую {{path}}" + "uk": "Редагую {{path}}", + "ca": "S'edita {{path}}" }, "ACTION_MESSAGE$WRITE": { "en": "Writing to {{path}}", @@ -8461,7 +8989,8 @@ "pt": "Escrevendo em {{path}}", "es": "Escribiendo en {{path}}", "tr": "{{path}} dosyasına yazılıyor", - "uk": "Записую в {{path}}" + "uk": "Записую в {{path}}", + "ca": "S'escriu a {{path}}" }, "ACTION_MESSAGE$BROWSE": { "en": "Browsing the web", @@ -8477,7 +9006,8 @@ "pt": "Navegando na web", "es": "Navegando en la web", "tr": "Web'de geziniyor", - "uk": "Перегляд веб-сторінок" + "uk": "Перегляд веб-сторінок", + "ca": "Navegant per la web" }, "ACTION_MESSAGE$BROWSE_INTERACTIVE": { "en": "Interactive browsing in progress...", @@ -8493,7 +9023,8 @@ "pt": "Navegação interativa em andamento...", "es": "Navegación interactiva en progreso...", "tr": "Etkileşimli tarama devam ediyor...", - "uk": "Триває інтерактивний перегляд..." + "uk": "Триває інтерактивний перегляд...", + "ca": "Navegació interactiva en curs..." }, "ACTION_MESSAGE$THINK": { "en": "Thinking", @@ -8509,7 +9040,8 @@ "pt": "Pensando", "es": "Pensando", "tr": "Düşünüyor", - "uk": "Розмірковую" + "uk": "Розмірковую", + "ca": "Pensant" }, "ACTION_MESSAGE$SYSTEM": { "en": "System Message", @@ -8525,7 +9057,8 @@ "pt": "Mensagem do Sistema", "es": "Mensaje del Sistema", "tr": "Sistem Mesajı", - "uk": "Системне повідомлення" + "uk": "Системне повідомлення", + "ca": "Missatge del sistema" }, "ACTION_MESSAGE$CONDENSATION": { "en": "Condensation", @@ -8541,7 +9074,8 @@ "pt": "Condensação", "es": "Condensación", "tr": "Yoğunlaşma", - "uk": "Конденсація" + "uk": "Конденсація", + "ca": "Condensació" }, "ACTION_MESSAGE$TASK_TRACKING": { "en": "Managing tasks", @@ -8557,7 +9091,8 @@ "pt": "Gerenciando tarefas", "es": "Gestionando tareas", "tr": "Görevleri yönetiyor", - "uk": "Керування завданнями" + "uk": "Керування завданнями", + "ca": "Gestionant tasques" }, "ACTION_MESSAGE$GREP": { "en": "Search in files: {{pattern}}", @@ -8573,7 +9108,8 @@ "fr": "Rechercher dans les fichiers: {{pattern}}", "tr": "Dosyalarda ara: {{pattern}}", "de": "In Dateien suchen: {{pattern}}", - "uk": "Пошук у файлах: {{pattern}}" + "uk": "Пошук у файлах: {{pattern}}", + "ca": "Cerca als fitxers: {{pattern}}" }, "ACTION_MESSAGE$GLOB": { "en": "Search files: {{pattern}}", @@ -8589,7 +9125,8 @@ "fr": "Rechercher des fichiers: {{pattern}}", "tr": "Dosya ara: {{pattern}}", "de": "Dateien suchen: {{pattern}}", - "uk": "Пошук файлів: {{pattern}}" + "uk": "Пошук файлів: {{pattern}}", + "ca": "Cerca fitxers: {{pattern}}" }, "OBSERVATION_MESSAGE$RUN": { "en": "Ran {{command}}", @@ -8605,7 +9142,8 @@ "pt": "Executou {{command}}", "es": "Ejecutó {{command}}", "tr": "{{command}} çalıştırıldı", - "uk": "Запустив {{command}}" + "uk": "Запустив {{command}}", + "ca": "S'ha executat {{command}}" }, "OBSERVATION_MESSAGE$RUN_IPYTHON": { "en": "Ran a Python command", @@ -8621,7 +9159,8 @@ "pt": "Executou um comando Python", "es": "Ejecutó un comando Python", "tr": "Python komutu çalıştırıldı", - "uk": "Виконав команду Python" + "uk": "Виконав команду Python", + "ca": "S'ha executat una comanda Python" }, "OBSERVATION_MESSAGE$READ": { "en": "Read {{path}}", @@ -8637,7 +9176,8 @@ "pt": "Leu {{path}}", "es": "Leyó {{path}}", "tr": "{{path}} okundu", - "uk": "Прочитав {{path}}" + "uk": "Прочитав {{path}}", + "ca": "S'ha llegit {{path}}" }, "OBSERVATION_MESSAGE$EDIT": { "en": "Edited {{path}}", @@ -8653,7 +9193,8 @@ "pt": "Editou {{path}}", "es": "Editó {{path}}", "tr": "{{path}} düzenlendi", - "uk": "Відредагував {{path}}" + "uk": "Відредагував {{path}}", + "ca": "S'ha editat {{path}}" }, "OBSERVATION_MESSAGE$WRITE": { "en": "Wrote to {{path}}", @@ -8669,7 +9210,8 @@ "pt": "Escreveu em {{path}}", "es": "Escribió en {{path}}", "tr": "{{path}} dosyasına yazıldı", - "uk": "Записав на {{path}}" + "uk": "Записав на {{path}}", + "ca": "S'ha escrit a {{path}}" }, "OBSERVATION_MESSAGE$BROWSE": { "en": "Browsing completed", @@ -8685,7 +9227,8 @@ "pt": "Navegação concluída", "es": "Navegación completada", "tr": "Gezinme tamamlandı", - "uk": "Перегляд завершено" + "uk": "Перегляд завершено", + "ca": "Navegació completada" }, "OBSERVATION_MESSAGE$MCP": { "en": "MCP Tool Result: {{mcp_tool_name}}", @@ -8701,7 +9244,8 @@ "pt": "Resultado da ferramenta MCP: {{mcp_tool_name}}", "es": "Resultado de la herramienta MCP: {{mcp_tool_name}}", "tr": "MCP Aracı Sonucu: {{mcp_tool_name}}", - "uk": "Результат інструменту MCP: {{mcp_tool_name}}" + "uk": "Результат інструменту MCP: {{mcp_tool_name}}", + "ca": "Resultat de l'eina MCP: {{mcp_tool_name}}" }, "OBSERVATION_MESSAGE$RECALL": { "en": "Microagent ready", @@ -8717,7 +9261,8 @@ "fr": "Microagent prêt", "tr": "MikroAjan hazır", "de": "Microagent bereit", - "uk": "Мікроагент готовий" + "uk": "Мікроагент готовий", + "ca": "Microagent preparat" }, "OBSERVATION_MESSAGE$THINK": { "en": "Thought", @@ -8733,7 +9278,8 @@ "fr": "Pensée", "tr": "Düşünce", "de": "Gedanke", - "uk": "Думка" + "uk": "Думка", + "ca": "Pensament" }, "OBSERVATION_MESSAGE$GLOB": { "en": "Search files: {{pattern}}", @@ -8749,7 +9295,8 @@ "fr": "Rechercher des fichiers: {{pattern}}", "tr": "Dosya ara: {{pattern}}", "de": "Dateien suchen: {{pattern}}", - "uk": "Пошук файлів: {{pattern}}" + "uk": "Пошук файлів: {{pattern}}", + "ca": "Cerca fitxers: {{pattern}}" }, "OBSERVATION_MESSAGE$GREP": { "en": "Search in files: {{pattern}}", @@ -8765,7 +9312,8 @@ "fr": "Rechercher dans les fichiers: {{pattern}}", "tr": "Dosyalarda ara: {{pattern}}", "de": "In Dateien suchen: {{pattern}}", - "uk": "Пошук у файлах: {{pattern}}" + "uk": "Пошук у файлах: {{pattern}}", + "ca": "Cerca als fitxers: {{pattern}}" }, "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN": { "en": "Agent updated the plan", @@ -8781,7 +9329,8 @@ "pt": "O agente atualizou o plano", "es": "El agente actualizó el plan", "tr": "Ajan planı güncelledi", - "uk": "Агент оновив план" + "uk": "Агент оновив план", + "ca": "L'agent ha actualitzat el pla" }, "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW": { "en": "Agent checked the current plan", @@ -8797,7 +9346,8 @@ "pt": "O agente verificou o plano atual", "es": "El agente verificó el plan actual", "tr": "Ajan mevcut planı kontrol etti", - "uk": "Агент перевірив поточний план" + "uk": "Агент перевірив поточний план", + "ca": "L'agent ha consultat el pla actual" }, "EXPANDABLE_MESSAGE$SHOW_DETAILS": { "en": "Show details", @@ -8813,7 +9363,8 @@ "pt": "Mostrar detalhes", "es": "Mostrar detalles", "tr": "Detayları göster", - "uk": "Показати деталі" + "uk": "Показати деталі", + "ca": "Mostra els detalls" }, "EXPANDABLE_MESSAGE$HIDE_DETAILS": { "en": "Hide details", @@ -8829,7 +9380,8 @@ "pt": "Ocultar detalhes", "es": "Ocultar detalles", "tr": "Detayları gizle", - "uk": "Приховати деталі" + "uk": "Приховати деталі", + "ca": "Amaga els detalls" }, "AI_SETTINGS$TITLE": { "en": "AI Provider Configuration", @@ -8845,7 +9397,8 @@ "fr": "Configuration du fournisseur d'IA", "tr": "AI Sağlayıcı Yapılandırması", "de": "Einstellungen", - "uk": "Конфігурація постачальника ШІ" + "uk": "Конфігурація постачальника ШІ", + "ca": "Configuració del proveïdor d'IA" }, "SETTINGS$DESCRIPTION": { "en": "To continue, connect an OpenAI, Anthropic, or other LLM account", @@ -8861,7 +9414,8 @@ "fr": "Pour continuer, connectez un compte OpenAI, Anthropic ou autre LLM", "tr": "Devam etmek için bir OpenAI, Anthropic veya başka bir LLM hesabı bağlayın", "de": "Konfigurieren Sie Ihre OpenHands-Umgebung", - "uk": "Щоб продовжити, підключіть обліковий запис OpenAI, Anthropic або інший LLM" + "uk": "Щоб продовжити, підключіть обліковий запис OpenAI, Anthropic або інший LLM", + "ca": "Per continuar, connecteu un compte d'OpenAI, Anthropic o un altre LLM" }, "SETTINGS$WARNING": { "en": "Changing settings during an active session will end the session", @@ -8877,7 +9431,8 @@ "fr": "La modification des paramètres pendant une session active mettra fin à la session", "tr": "Aktif bir oturum sırasında ayarları değiştirmek oturumu sonlandıracaktır", "de": "Einige Einstellungen können nicht geändert werden, während der Agent läuft", - "uk": "Зміна налаштувань під час активного сеансу призведе до його завершення." + "uk": "Зміна налаштувань під час активного сеансу призведе до його завершення.", + "ca": "Canviar la configuració durant una sessió activa finalitzarà la sessió" }, "SIDEBAR$SETTINGS": { "en": "Settings", @@ -8893,7 +9448,8 @@ "fr": "Paramètres", "tr": "Ayarlar", "de": "Einstellungen", - "uk": "Налаштування" + "uk": "Налаштування", + "ca": "Configuració" }, "SIDEBAR$DOCS": { "en": "Documentation", @@ -8909,7 +9465,8 @@ "fr": "Documentation", "tr": "Belgeler", "de": "Dokumentation", - "uk": "Документація" + "uk": "Документація", + "ca": "Documentació" }, "SUGGESTIONS$ADD_DOCS": { "en": "Add best practices docs for contributors", @@ -8925,7 +9482,8 @@ "fr": "Ajouter des documents sur les meilleures pratiques pour les contributeurs", "tr": "Katkıda bulunanlar için en iyi uygulama belgelerini ekle", "de": "Dokumentation hinzufügen", - "uk": "Додайте документи з найкращими практиками для учасників" + "uk": "Додайте документи з найкращими практиками для учасників", + "ca": "Afegeix documentació de bones pràctiques per als col·laboradors" }, "SUGGESTIONS$ADD_DOCKERFILE": { "en": "Add/improve a Dockerfile", @@ -8941,7 +9499,8 @@ "fr": "Ajouter/améliorer un Dockerfile", "tr": "Dockerfile ekle/geliştir", "de": "Dockerfile hinzufügen", - "uk": "Додати/покращити Dockerfile" + "uk": "Додати/покращити Dockerfile", + "ca": "Afegeix/millora un Dockerfile" }, "STATUS$CONNECTED": { "en": "Connected", @@ -8957,7 +9516,8 @@ "ar": "متصل", "fr": "Connecté", "tr": "Bağlandı", - "uk": "Підключено" + "uk": "Підключено", + "ca": "Connectat" }, "STATUS$CONNECTION_LOST": { "en": "Connection lost", @@ -8973,7 +9533,8 @@ "ar": "فُقد الاتصال", "fr": "Connexion perdue", "tr": "Bağlantı kesildi", - "uk": "Втрачено з'єднання" + "uk": "Втрачено з'єднання", + "ca": "Connexió perduda" }, "STATUS$DISCONNECTED_REFRESH_PAGE": { "en": "Disconnected. Please refresh the page", @@ -8989,7 +9550,8 @@ "ar": "تم قطع الاتصال. يرجى تحديث الصفحة", "fr": "Déconnecté. Veuillez actualiser la page", "tr": "Bağlantı kesildi. Lütfen sayfayı yenileyin", - "uk": "Відключено. Будь ласка, оновіть сторінку" + "uk": "Відключено. Будь ласка, оновіть сторінку", + "ca": "Desconnectat. Actualitzeu la pàgina" }, "BROWSER$NO_PAGE_LOADED": { "en": "No page loaded yet. Ask OpenHands to open a URL. Example: \"Open https://example.com\"", @@ -9005,7 +9567,8 @@ "ar": "لم يتم تحميل أي صفحة بعد. اطلب من OpenHands فتح عنوان URL. مثال: \"Open https://example.com\"", "fr": "Aucune page n'a encore été chargée. Demandez à OpenHands d'ouvrir une URL. Exemple : \"Open https://example.com\"", "tr": "Henüz hiçbir sayfa yüklenmedi. OpenHands'e bir URL açmasını isteyin. Örnek: \"Open https://example.com\"", - "uk": "Сторінка ще не завантажена. Попросіть OpenHands відкрити URL-адресу. Приклад: \"Open https://example.com\"" + "uk": "Сторінка ще не завантажена. Попросіть OpenHands відкрити URL-адресу. Приклад: \"Open https://example.com\"", + "ca": "Encara no s'ha carregat cap pàgina. Demaneu a OpenHands que obri una URL. Exemple: \"Obre https://example.com\"" }, "USER$AVATAR_PLACEHOLDER": { "en": "user avatar placeholder", @@ -9021,7 +9584,8 @@ "ar": "عنصر نائب لصورة المستخدم", "no": "plassholder for brukeravatar", "tr": "Kullanıcı avatarı yer tutucusu", - "uk": "заповнювач аватара користувача" + "uk": "заповнювач аватара користувача", + "ca": "marcador de posició de l'avatar de l'usuari" }, "ACCOUNT_SETTINGS$LOGOUT": { "en": "Logout", @@ -9037,7 +9601,8 @@ "fr": "Déconnexion", "tr": "Çıkış yap", "de": "Abmelden", - "uk": "Вийти" + "uk": "Вийти", + "ca": "Tanca la sessió" }, "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL": { "en": "Advanced Options", @@ -9053,7 +9618,8 @@ "it": "Opzioni avanzate", "pt": "Opções avançadas", "tr": "Gelişmiş seçenekler", - "uk": "Розширені параметри" + "uk": "Розширені параметри", + "ca": "Opcions avançades" }, "CONVERSATION$NO_CONVERSATIONS": { "en": "No conversations found", @@ -9069,7 +9635,8 @@ "ar": "لم يتم العثور على محادثات", "no": "Ingen samtaler funnet", "tr": "Konuşma yok", - "uk": "Розмов не знайдено" + "uk": "Розмов не знайдено", + "ca": "No s'han trobat converses" }, "LANDING$SELECT_GIT_REPO": { "en": "Select a Git project", @@ -9085,7 +9652,8 @@ "ar": "اختر مشروع Git", "no": "Velg et Git-prosjekt", "tr": "Depo seç", - "uk": "Виберіть Git-проект" + "uk": "Виберіть Git-проект", + "ca": "Selecciona un projecte Git" }, "BUTTON$SEND": { "en": "Send", @@ -9101,7 +9669,8 @@ "ar": "إرسال", "no": "Send", "tr": "Gönder", - "uk": "Надіслати" + "uk": "Надіслати", + "ca": "Envia" }, "STATUS$BUILDING_RUNTIME": { "en": "Building Runtime...", @@ -9117,7 +9686,8 @@ "fr": "Construction de l'environnement d'exécution...", "tr": "Çalışma ortamı oluşturuluyor...", "de": "Laufzeitumgebung wird erstellt...", - "uk": "Створення середовища виконання..." + "uk": "Створення середовища виконання...", + "ca": "Construint l'entorn d'execució..." }, "SUGGESTIONS$WHAT_TO_BUILD": { "en": "What do you want to build?", @@ -9133,7 +9703,8 @@ "fr": "Que voulez-vous construire ?", "tr": "Ne inşa etmek istiyorsun?", "de": "Was möchten Sie erstellen?", - "uk": "Що ви хочете побудувати?" + "uk": "Що ви хочете побудувати?", + "ca": "Què vols construir?" }, "SETTINGS_FORM$ENABLE_DEFAULT_CONDENSER_SWITCH_LABEL": { "en": "Enable Memory Condenser", @@ -9149,7 +9720,8 @@ "it": "Abilita condensatore di memoria", "pt": "Ativar condensador de memória", "es": "Habilitar condensador de memoria", - "tr": "Bellek Yoğunlaştırıcıyı Etkinleştir" + "tr": "Bellek Yoğunlaştırıcıyı Etkinleştir", + "ca": "Activa el condensador de memòria" }, "BUTTON$MARK_HELPFUL": { "en": "Mark this solution as helpful", @@ -9165,7 +9737,8 @@ "fr": "Marquer cette solution comme utile", "tr": "Bu çözümü yararlı olarak işaretle", "ja": "このソリューションが役立つと評価", - "uk": "Позначити це рішення як корисне" + "uk": "Позначити це рішення як корисне", + "ca": "Marca aquesta solució com a útil" }, "BUTTON$MARK_NOT_HELPFUL": { "en": "Mark this solution as not helpful", @@ -9181,7 +9754,8 @@ "fr": "Marquer cette solution comme non utile", "tr": "Bu çözümü yararlı değil olarak işaretle", "ja": "このソリューションが役立たないと評価", - "uk": "Позначити це рішення як некорисне" + "uk": "Позначити це рішення як некорисне", + "ca": "Marca aquesta solució com a no útil" }, "BUTTON$EXPORT_CONVERSATION": { "en": "Export Conversation", @@ -9197,7 +9771,8 @@ "fr": "Exporter la conversation", "tr": "Konuşmayı dışa aktar", "ja": "会話をエクスポート", - "uk": "Експорт розмови" + "uk": "Експорт розмови", + "ca": "Exporta la conversa" }, "BILLING$CLICK_TO_TOP_UP": { "en": "Add funds to Your Account", @@ -9213,7 +9788,8 @@ "fr": "Ajouter des fonds à votre compte", "tr": "Hesabınıza bakiye ekleyin", "de": "Guthaben zu Ihrem Konto hinzufügen", - "uk": "Додайте кошти до свого облікового запису" + "uk": "Додайте кошти до свого облікового запису", + "ca": "Afegiu fons al vostre compte" }, "BILLING$YOUVE_GOT_50": { "en": "You've got $50 in free OpenHands credits", @@ -9229,7 +9805,8 @@ "fr": "Vous avez reçu $50 de crédits OpenHands gratuits", "tr": "OpenHands'de $50 ücretsiz kredi kazandınız", "de": "Sie haben $50 in kostenlosen OpenHands-Guthaben erhalten", - "uk": "Ви отримали 50 доларів у безкоштовних кредитах OpenHands" + "uk": "Ви отримали 50 доларів у безкоштовних кредитах OpenHands", + "ca": "Teniu 50 $ en crèdits gratuïts d'OpenHands" }, "BILLING$ERROR_WHILE_CREATING_SESSION": { "en": "Error occurred while setting up your payment session. Please try again later.", @@ -9245,7 +9822,8 @@ "fr": "Une erreur s'est produite lors de la configuration de votre session de paiement. Veuillez réessayer plus tard.", "tr": "Ödeme oturumunuz kurulurken bir hata oluştu. Lütfen daha sonra tekrar deneyin.", "de": "Beim Einrichten Ihrer Zahlungssitzung ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.", - "uk": "Під час налаштування сеансу оплати сталася помилка. Будь ласка, спробуйте пізніше." + "uk": "Під час налаштування сеансу оплати сталася помилка. Будь ласка, спробуйте пізніше.", + "ca": "S'ha produït un error en configurar la sessió de pagament. Torneu-ho a intentar més tard." }, "BILLING$CLAIM_YOUR_50": { "en": "Add a credit card with Stripe to claim your $50. We won't charge you without asking first!", @@ -9261,7 +9839,8 @@ "fr": "Ajoutez une carte de crédit avec Stripe pour obtenir 50$. Nous ne vous facturerons pas sans vous demander d'abord !", "tr": "50$ almak için Stripe ile kredi kartı ekleyin. Önce sormadan ücret almayacağız!", "de": "Fügen Sie eine Kreditkarte mit Stripe hinzu, um $50 zu erhalten. Wir belasten Sie nicht ohne vorherige Zustimmung!", - "uk": "Додайте кредитну картку до Stripe, щоб отримати свої 50 доларів. Ми не стягуватимемо з вас плату без попереднього запиту!" + "uk": "Додайте кредитну картку до Stripe, щоб отримати свої 50 доларів. Ми не стягуватимемо з вас плату без попереднього запиту!", + "ca": "Afegiu una targeta de crèdit amb Stripe per reclamar els vostres 50 $. No us cobrarem sense demanar-vos-ho primer!" }, "BILLING$POWERED_BY": { "en": "Powered by", @@ -9277,7 +9856,8 @@ "fr": "Propulsé par", "tr": "Tarafından desteklenmektedir", "de": "Bereitgestellt von", - "uk": "Працює на базі" + "uk": "Працює на базі", + "ca": "Impulsat per" }, "BILLING$PROCEED_TO_STRIPE": { "en": "Add Billing Info", @@ -9293,7 +9873,8 @@ "fr": "Ajouter les informations de facturation", "tr": "Fatura Bilgisi Ekle", "de": "Zahlungsinformationen hinzufügen", - "uk": "Додати платіжну інформацію" + "uk": "Додати платіжну інформацію", + "ca": "Afegeix informació de facturació" }, "BILLING$YOURE_IN": { "en": "You're in! You can start using your $10 in free credits now.", @@ -9309,7 +9890,8 @@ "fr": "C'est fait ! Vous pouvez commencer à utiliser vos 10 $ de crédits gratuits maintenant.", "tr": "Başardın! Şimdi $10 değerindeki ücretsiz kredilerini kullanmaya başlayabilirsin.", "de": "Du bist dabei! Du kannst jetzt deine $10 an kostenlosen Guthaben nutzen.", - "uk": "Готово! Ви можете почати використовувати свої безкоштовні кредити на суму 10 доларів США вже зараз." + "uk": "Готово! Ви можете почати використовувати свої безкоштовні кредити на суму 10 доларів США вже зараз.", + "ca": "Ja hi sou! Podeu començar a fer servir els vostres 10 $ en crèdits gratuïts ara." }, "PAYMENT$ADD_FUNDS": { "en": "Add Funds", @@ -9325,7 +9907,8 @@ "fr": "Ajouter des fonds", "tr": "Bakiye Ekle", "de": "Guthaben hinzufügen", - "uk": "Додати кошти" + "uk": "Додати кошти", + "ca": "Afegeix fons" }, "PAYMENT$ADD_CREDIT": { "en": "Add credit", @@ -9341,7 +9924,8 @@ "fr": "Ajouter du crédit", "tr": "Kredi ekle", "de": "Guthaben hinzufügen", - "uk": "Додати кредит" + "uk": "Додати кредит", + "ca": "Afegeix crèdit" }, "PAYMENT$MANAGE_CREDITS": { "en": "Manage Credits", @@ -9357,7 +9941,8 @@ "fr": "Gérer les crédits", "tr": "Kredileri yönet", "de": "Guthaben verwalten", - "uk": "Керування кредитами" + "uk": "Керування кредитами", + "ca": "Gestiona els crèdits" }, "PAYMENT$CANCEL_SUBSCRIPTION": { "en": "Cancel Subscription", @@ -9373,7 +9958,8 @@ "fr": "Annuler l'abonnement", "tr": "Aboneliği iptal et", "de": "Abonnement kündigen", - "uk": "Скасувати підписку" + "uk": "Скасувати підписку", + "ca": "Cancel·la la subscripció" }, "PAYMENT$CANCEL_SUBSCRIPTION_TITLE": { "en": "Cancel Subscription", @@ -9389,7 +9975,8 @@ "fr": "Annuler l'abonnement", "tr": "Aboneliği iptal et", "de": "Abonnement kündigen", - "uk": "Скасувати підписку" + "uk": "Скасувати підписку", + "ca": "Cancel·la la subscripció" }, "PAYMENT$SUBSCRIPTION_CANCELLED": { "en": "Subscription cancelled successfully", @@ -9405,7 +9992,8 @@ "fr": "Abonnement annulé avec succès", "tr": "Abonelik başarıyla iptal edildi", "de": "Abonnement erfolgreich gekündigt", - "uk": "Підписку успішно скасовано" + "uk": "Підписку успішно скасовано", + "ca": "La subscripció s'ha cancel·lat correctament" }, "PAYMENT$NEXT_BILLING_DATE": { "en": "Next billing date: {{date}}", @@ -9421,7 +10009,8 @@ "fr": "Prochaine date de facturation: {{date}}", "tr": "Sonraki fatura tarihi: {{date}}", "de": "Nächstes Abrechnungsdatum: {{date}}", - "uk": "Наступна дата виставлення рахунку: {{date}}" + "uk": "Наступна дата виставлення рахунку: {{date}}", + "ca": "Propera data de facturació: {{date}}" }, "WAITLIST$IF_NOT_JOINED": { "en": "If you haven't already joined the waitlist, please do so to get access.", @@ -9437,7 +10026,8 @@ "fr": "Si vous n'avez pas encore rejoint la liste d'attente, veuillez le faire pour obtenir l'accès.", "tr": "Henüz bekleme listesine katılmadıysanız, erişim elde etmek için lütfen katılın.", "de": "Wenn Sie der Warteliste noch nicht beigetreten sind, tun Sie dies bitte, um Zugang zu erhalten.", - "uk": "Якщо ви ще не записалися до списку очікування, будь ласка, зробіть це, щоб отримати доступ." + "uk": "Якщо ви ще не записалися до списку очікування, будь ласка, зробіть це, щоб отримати доступ.", + "ca": "Si encara no us heu unit a la llista d'espera, feu-ho per obtenir accés." }, "WAITLIST$PATIENCE_MESSAGE": { "en": "Thank you for your patience. We're working hard to give you access as soon as possible.", @@ -9453,7 +10043,8 @@ "fr": "Merci pour votre patience. Nous travaillons dur pour vous donner accès dès que possible.", "tr": "Sabrınız için teşekkür ederiz. Size mümkün olan en kısa sürede erişim sağlamak için çok çalışıyoruz.", "de": "Vielen Dank für Ihre Geduld. Wir arbeiten hart daran, Ihnen so schnell wie möglich Zugang zu gewähren.", - "uk": "Дякуємо за ваше терпіння. Ми наполегливо працюємо, щоб надати вам доступ якомога швидше." + "uk": "Дякуємо за ваше терпіння. Ми наполегливо працюємо, щоб надати вам доступ якомога швидше.", + "ca": "Gràcies per la vostra paciència. Estem treballant dur per donar-vos accés el més aviat possible." }, "WAITLIST$ALMOST_THERE": { "en": "Almost there!", @@ -9469,7 +10060,8 @@ "fr": "Presque là !", "tr": "Neredeyse tamam!", "de": "Fast geschafft!", - "uk": "Майже готово!" + "uk": "Майже готово!", + "ca": "Ja quasi hi sou!" }, "PAYMENT$SUCCESS": { "en": "Payment successful", @@ -9485,7 +10077,8 @@ "fr": "Paiement réussi", "tr": "Ödeme başarılı", "de": "Zahlung erfolgreich", - "uk": "Оплата успішна" + "uk": "Оплата успішна", + "ca": "Pagament realitzat correctament" }, "PAYMENT$CANCELLED": { "en": "Payment cancelled", @@ -9501,7 +10094,8 @@ "fr": "Paiement annulé", "tr": "Ödeme iptal edildi", "de": "Zahlung abgebrochen", - "uk": "Платіж скасовано" + "uk": "Платіж скасовано", + "ca": "Pagament cancel·lat" }, "SUBSCRIPTION$SUCCESS": { "en": "Subscription successful! You now have access to premium features.", @@ -9517,7 +10111,8 @@ "fr": "Abonnement réussi ! Vous avez maintenant accès aux fonctionnalités premium.", "tr": "Abonelik başarılı! Artık premium özelliklere erişiminiz var.", "de": "Abonnement erfolgreich! Sie haben jetzt Zugang zu Premium-Funktionen.", - "uk": "Підписка успішна! Тепер у вас є доступ до преміум-функцій." + "uk": "Підписка успішна! Тепер у вас є доступ до преміум-функцій.", + "ca": "Subscripció realitzada correctament! Ara teniu accés a les funcions premium." }, "SUBSCRIPTION$FAILURE": { "en": "Something went wrong, please try again later.", @@ -9533,7 +10128,8 @@ "fr": "Quelque chose s'est mal passé, veuillez réessayer plus tard.", "tr": "Bir şeyler ters gitti, lütfen daha sonra tekrar deneyin.", "de": "Etwas ist schief gelaufen, bitte versuchen Sie es später erneut.", - "uk": "Щось пішло не так, спробуйте ще раз пізніше." + "uk": "Щось пішло не так, спробуйте ще раз пізніше.", + "ca": "Alguna cosa ha anat malament, torneu-ho a intentar més tard." }, "SERVED_APP$TITLE": { "en": "Served Application", @@ -9549,7 +10145,8 @@ "fr": "Application servie", "tr": "Sunulan Uygulama", "de": "Bereitgestellte Anwendung", - "uk": "Оброблений додаток" + "uk": "Оброблений додаток", + "ca": "Aplicació servida" }, "CONVERSATION$UNKNOWN": { "en": "unknown", @@ -9565,7 +10162,8 @@ "ar": "غير معروف", "fr": "inconnu", "tr": "bilinmeyen", - "uk": "невідомий" + "uk": "невідомий", + "ca": "desconegut" }, "SETTINGS$RUNTIME_OPTION_1X": { "en": "1x (2 core, 8G)", @@ -9581,7 +10179,8 @@ "ar": "1x (2 نواة, 8G)", "fr": "1x (2 cœur, 8G)", "tr": "1x (2 çekirdek, 8G)", - "uk": "1x (2 ядра, 8G)" + "uk": "1x (2 ядра, 8G)", + "ca": "1x (2 nuclis, 8 GB)" }, "SETTINGS$RUNTIME_OPTION_2X": { "en": "2x (4 core, 16G)", @@ -9597,7 +10196,8 @@ "ar": "2x (4 نواة, 16G)", "fr": "2x (4 cœur, 16G)", "tr": "2x (4 çekirdek, 16G)", - "uk": "2x (4 ядра, 16G)" + "uk": "2x (4 ядра, 16G)", + "ca": "2x (4 nuclis, 16 GB)" }, "SETTINGS$GET_IN_TOUCH": { "en": "get in touch for access", @@ -9613,7 +10213,8 @@ "ar": "تواصل معنا للوصول", "fr": "contactez-nous pour l'accès", "tr": "erişim için iletişime geçin", - "uk": "зв'яжіться з нами для отримання доступу" + "uk": "зв'яжіться з нами для отримання доступу", + "ca": "poseu-vos en contacte per obtenir accés" }, "CONVERSATION$NO_METRICS": { "en": "No metrics data available", @@ -9629,7 +10230,8 @@ "ar": "لا توجد بيانات قياس متاحة", "fr": "Aucune donnée métrique disponible", "tr": "Kullanılabilir metrik verisi yok", - "uk": "Немає даних про показники" + "uk": "Немає даних про показники", + "ca": "No hi ha dades de mètriques disponibles" }, "CONVERSATION$DOWNLOAD_ERROR": { "en": "ConversationId unknown, cannot download trajectory", @@ -9645,7 +10247,8 @@ "ar": "معرف المحادثة غير معروف، لا يمكن تنزيل المسار", "fr": "ID de conversation inconnu, impossible de télécharger la trajectoire", "tr": "Konuşma kimliği bilinmiyor, yörünge indirilemiyor", - "uk": "Ідентифікатор розмови невідомий, неможливо завантажити траєкторію" + "uk": "Ідентифікатор розмови невідомий, неможливо завантажити траєкторію", + "ca": "ConversationId desconegut, no es pot descarregar la trajectòria" }, "CONVERSATION$UPDATED": { "en": ", updated", @@ -9661,7 +10264,8 @@ "ar": "، تم التحديث", "fr": ", mis à jour", "tr": ", güncellendi", - "uk": ", оновлено" + "uk": ", оновлено", + "ca": ", actualitzat" }, "CONVERSATION$TOTAL_COST": { "en": "Total Cost", @@ -9677,7 +10281,8 @@ "ar": "التكلفة الإجمالية", "fr": "Coût total ", "tr": "Toplam Maliyet", - "uk": "Загальна вартість" + "uk": "Загальна вартість", + "ca": "Cost total" }, "CONVERSATION$BUDGET": { "en": "Budget", @@ -9693,7 +10298,8 @@ "ar": "الميزانية", "fr": "Budget", "tr": "Bütçe", - "uk": "Бюджет" + "uk": "Бюджет", + "ca": "Pressupost" }, "CONVERSATION$BUDGET_USAGE": { "en": "% used", @@ -9709,7 +10315,8 @@ "ar": "% مستخدم", "fr": "% utilisé", "tr": "% kullanıldı", - "uk": "% використано" + "uk": "% використано", + "ca": "% utilitzat" }, "CONVERSATION$NO_BUDGET_LIMIT": { "en": "No budget limit", @@ -9725,7 +10332,8 @@ "ar": "لا حد للميزانية", "fr": "Pas de limite de budget", "tr": "Bütçe limiti yok", - "uk": "Без обмеження бюджету" + "uk": "Без обмеження бюджету", + "ca": "Sense límit de pressupost" }, "CONVERSATION$INPUT": { "en": "- Input:", @@ -9741,7 +10349,8 @@ "ar": "- المدخلات:", "fr": "- Entrée :", "tr": "- Giriş:", - "uk": "- Вхідні дані:" + "uk": "- Вхідні дані:", + "ca": "- Entrada:" }, "CONVERSATION$OUTPUT": { "en": "- Output:", @@ -9757,7 +10366,8 @@ "ar": "- المخرجات:", "fr": "- Sortie :", "tr": "- Çıkış:", - "uk": "- Результат:" + "uk": "- Результат:", + "ca": "- Sortida:" }, "CONVERSATION$TOTAL": { "en": "- Total:", @@ -9773,7 +10383,8 @@ "ar": "- المجموع:", "fr": "- Total :", "tr": "- Toplam:", - "uk": "- Всього:" + "uk": "- Всього:", + "ca": "- Total:" }, "CONVERSATION$CONTEXT_WINDOW": { "en": "Context Window", @@ -9789,7 +10400,8 @@ "ar": "نافذة السياق", "fr": "Fenêtre de contexte", "tr": "Bağlam Penceresi", - "uk": "Вікно контексту" + "uk": "Вікно контексту", + "ca": "Finestra de context" }, "CONVERSATION$USED": { "en": "used", @@ -9805,7 +10417,8 @@ "ar": "مستخدم", "fr": "utilisé", "tr": "kullanıldı", - "uk": "використано" + "uk": "використано", + "ca": "utilitzat" }, "SETTINGS$RESET_CONFIRMATION": { "en": "Are you sure you want to reset all settings?", @@ -9821,7 +10434,8 @@ "ar": "هل أنت متأكد أنك تريد إعادة تعيين جميع الإعدادات؟", "fr": "Êtes-vous sûr de vouloir réinitialiser tous les paramètres ?", "tr": "Tüm ayarları sıfırlamak istediğinizden emin misiniz?", - "uk": "Ви впевнені, що хочете скинути всі налаштування?" + "uk": "Ви впевнені, що хочете скинути всі налаштування?", + "ca": "Esteu segur que voleu restablir tota la configuració?" }, "ERROR$GENERIC_OOPS": { "en": "Oops! An error occurred!", @@ -9837,7 +10451,8 @@ "ar": "عفوا! حدث خطأ!", "fr": "Oups ! Une erreur s'est produite !", "tr": "Hay aksi! Bir hata oluştu!", - "uk": "Ой! Сталася помилка!" + "uk": "Ой! Сталася помилка!", + "ca": "Vaja! S'ha produït un error!" }, "ERROR$UNKNOWN": { "en": "Uh oh, an unknown error occurred!", @@ -9853,7 +10468,8 @@ "ar": "أوه، حدث خطأ غير معروف!", "fr": "Oh non, une erreur inconnue s'est produite !", "tr": "Hay aksi, bilinmeyen bir hata oluştu!", - "uk": "Ой, сталася невідома помилка!" + "uk": "Ой, сталася невідома помилка!", + "ca": "Vaja, s'ha produït un error desconegut!" }, "SETTINGS$FOR_OTHER_OPTIONS": { "en": "For other options", @@ -9869,7 +10485,8 @@ "ar": "للخيارات الأخرى،", "fr": "Pour d'autres options", "tr": "Diğer seçenekler için", - "uk": "Щодо інших варіантів" + "uk": "Щодо інших варіантів", + "ca": "Per a altres opcions" }, "SETTINGS$SEE_ADVANCED_SETTINGS": { "en": "see advanced settings", @@ -9885,7 +10502,8 @@ "ar": "انظر الإعدادات المتقدمة", "fr": "voir les paramètres avancés", "tr": "gelişmiş ayarlara bakın", - "uk": "перегляньте розширені налаштування" + "uk": "перегляньте розширені налаштування", + "ca": "consulteu la configuració avançada" }, "SETTINGS_FORM$API_KEY": { "en": "API Key", @@ -9901,7 +10519,8 @@ "ar": "مفتاح API", "fr": "Clé API", "tr": "API Anahtarı", - "uk": "API ключ" + "uk": "API ключ", + "ca": "Clau d'API" }, "SETTINGS_FORM$BASE_URL": { "en": "Base URL", @@ -9917,7 +10536,8 @@ "ar": "عنوان URL الأساسي", "fr": "URL de base", "tr": "Temel URL", - "uk": "Базовий URL" + "uk": "Базовий URL", + "ca": "URL base" }, "GITHUB$CONNECT_TO_GITHUB": { "en": "Log in with GitHub", @@ -9933,7 +10553,8 @@ "ar": "الاتصال بـ GitHub", "fr": "Se connecter à GitHub", "tr": "GitHub'a bağlan", - "uk": "Увійти за допомогою GitHub" + "uk": "Увійти за допомогою GitHub", + "ca": "Inicia sessió amb GitHub" }, "GITLAB$CONNECT_TO_GITLAB": { "en": "Log in with GitLab", @@ -9949,7 +10570,8 @@ "ar": "الاتصال بـ GitLab", "fr": "Se connecter à GitLab", "tr": "GitLab'a bağlan", - "uk": "Увійти за допомогою GitLab" + "uk": "Увійти за допомогою GitLab", + "ca": "Inicia sessió amb GitLab" }, "BITBUCKET$CONNECT_TO_BITBUCKET": { "en": "Log in with Bitbucket", @@ -9965,7 +10587,8 @@ "ar": "الاتصال بـ Bitbucket", "fr": "Se connecter à Bitbucket", "tr": "Bitbucket'a bağlan", - "uk": "Увійти за допомогою Bitbucket" + "uk": "Увійти за допомогою Bitbucket", + "ca": "Inicia sessió amb Bitbucket" }, "BITBUCKET_DATA_CENTER$CONNECT_TO_BITBUCKET_DATA_CENTER": { "en": "Log in with Bitbucket Data Center", @@ -9981,7 +10604,8 @@ "ar": "الاتصال بـ Bitbucket Data Center", "fr": "Se connecter à Bitbucket Data Center", "tr": "Bitbucket Data Center'a bağlan", - "uk": "Увійти за допомогою Bitbucket Data Center" + "uk": "Увійти за допомогою Bitbucket Data Center", + "ca": "Inicia sessió amb Bitbucket Data Center" }, "ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO": { "en": "Login with Enterprise SSO", @@ -9997,7 +10621,8 @@ "ar": "تسجيل الدخول باستخدام Enterprise SSO", "fr": "Se connecter avec Enterprise SSO", "tr": "Enterprise SSO ile giriş yap", - "uk": "Увійти за допомогою Enterprise SSO" + "uk": "Увійти за допомогою Enterprise SSO", + "ca": "Inicia sessió amb SSO empresarial" }, "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER": { "en": "Log in to OpenHands", @@ -10013,7 +10638,8 @@ "fr": "Connectez-vous avec votre fournisseur d'identité", "tr": "Kimlik sağlayıcınızla giriş yapın", "de": "Melden Sie sich mit Ihrem Identitätsanbieter an", - "uk": "Увійти до OpenHands" + "uk": "Увійти до OpenHands", + "ca": "Inicia sessió a OpenHands" }, "WAITLIST$JOIN_WAITLIST": { "en": "Join Waitlist", @@ -10029,7 +10655,8 @@ "ar": "الانضمام إلى قائمة الانتظار", "fr": "Rejoindre la liste d'attente", "tr": "Bekleme listesine katıl", - "uk": "Приєднатися до списку очікування" + "uk": "Приєднатися до списку очікування", + "ca": "Uneix-te a la llista d'espera" }, "CONVERSATION$DELETE_WARNING": { "en": "Are you sure you want to delete this conversation? This action cannot be undone.", @@ -10045,7 +10672,8 @@ "fr": "Êtes-vous sûr de vouloir supprimer cette conversation ? Cette action ne peut pas être annulée.", "tr": "Bu konuşmayı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "de": "Sind Sie sicher, dass Sie dieses Gespräch löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "uk": "Ви впевнені, що хочете видалити цю розмову? Цю дію не можна скасувати." + "uk": "Ви впевнені, що хочете видалити цю розмову? Цю дію не можна скасувати.", + "ca": "Esteu segur que voleu eliminar aquesta conversa? Aquesta acció no es pot desfer." }, "CONVERSATION$DELETE_WARNING_WITH_TITLE": { "en": "Are you sure you want to delete the \"{{title}}\" conversation? This action cannot be undone.", @@ -10061,7 +10689,8 @@ "fr": "Êtes-vous sûr de vouloir supprimer la conversation « {{title}} » ? Cette action ne peut pas être annulée.", "tr": "\"{{title}}\" konuşmasını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "de": "Sind Sie sicher, dass Sie das Gespräch „{{title}}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "uk": "Ви впевнені, що хочете видалити розмову «{{title}}»? Цю дію не можна скасувати." + "uk": "Ви впевнені, що хочете видалити розмову «{{title}}»? Цю дію не можна скасувати.", + "ca": "Esteu segur que voleu eliminar la conversa \"{{title}}\"? Aquesta acció no es pot desfer." }, "FEEDBACK$TITLE": { "en": "Feedback", @@ -10077,7 +10706,8 @@ "fr": "Retour d'information", "tr": "Geri bildirim", "de": "Feedback", - "uk": "Зворотній зв'язок" + "uk": "Зворотній зв'язок", + "ca": "Comentaris" }, "FEEDBACK$DESCRIPTION": { "en": "We value your feedback. Please share your thoughts with us.", @@ -10093,7 +10723,8 @@ "fr": "Nous apprécions vos commentaires. Veuillez partager vos réflexions avec nous.", "tr": "Geri bildiriminizi değerlendiriyoruz. Lütfen düşüncelerinizi bizimle paylaşın.", "de": "Wir schätzen Ihr Feedback. Bitte teilen Sie uns Ihre Gedanken mit.", - "uk": "Ми цінуємо ваш відгук. Будь ласка, поділіться з нами своїми думками." + "uk": "Ми цінуємо ваш відгук. Будь ласка, поділіться з нами своїми думками.", + "ca": "Valorem els vostres comentaris. Compartiu les vostres opinions amb nosaltres." }, "EXIT_PROJECT$WARNING": { "en": "Are you sure you want to exit this project? Any unsaved changes will be lost.", @@ -10109,7 +10740,8 @@ "fr": "Êtes-vous sûr de vouloir quitter ce projet ? Toutes les modifications non enregistrées seront perdues.", "tr": "Bu projeden çıkmak istediğinizden emin misiniz? Kaydedilmemiş değişiklikler kaybolacaktır.", "de": "Sind Sie sicher, dass Sie dieses Projekt beenden möchten? Alle nicht gespeicherten Änderungen gehen verloren.", - "uk": "Ви впевнені, що хочете вийти з цього проєкту? Усі незбережені зміни буде втрачено." + "uk": "Ви впевнені, що хочете вийти з цього проєкту? Усі незбережені зміни буде втрачено.", + "ca": "Esteu segur que voleu sortir d'aquest projecte? Es perdran els canvis no desats." }, "MODEL_SELECTOR$VERIFIED": { "en": "Verified Models", @@ -10125,7 +10757,8 @@ "fr": "Modèles vérifiés", "tr": "Doğrulanmış Modeller", "de": "Verifizierte Modelle", - "uk": "Перевірені моделі" + "uk": "Перевірені моделі", + "ca": "Models verificats" }, "MODEL_SELECTOR$OTHERS": { "en": "Other Models", @@ -10141,7 +10774,8 @@ "fr": "Autres modèles", "tr": "Diğer Modeller", "de": "Andere Modelle", - "uk": "Інші моделі" + "uk": "Інші моделі", + "ca": "Altres models" }, "GITLAB$TOKEN_LABEL": { "en": "GitLab Token", @@ -10157,7 +10791,8 @@ "fr": "Jeton GitLab", "tr": "GitLab Jetonu", "de": "GitLab-Token", - "uk": "GitLab токен" + "uk": "GitLab токен", + "ca": "Token de GitLab" }, "GITLAB$HOST_LABEL": { "en": "GitLab Host (optional)", @@ -10173,7 +10808,8 @@ "fr": "Hôte GitLab (optionnel)", "tr": "GitLab Sunucusu (isteğe bağlı)", "de": "GitLab-Host (optional)", - "uk": "Хост GitLab (необов'язково)" + "uk": "Хост GitLab (необов'язково)", + "ca": "Servidor de GitLab (opcional)" }, "GITLAB$GET_TOKEN": { "en": "Generate a token on", @@ -10189,7 +10825,8 @@ "fr": "Générer un jeton sur", "tr": "Üzerinde bir jeton oluştur", "de": "Token generieren auf", - "uk": "Згенерувати токен на" + "uk": "Згенерувати токен на", + "ca": "Genera un token a" }, "GITLAB$TOKEN_HELP_TEXT": { "en": "Get your <0>GitLab token or <1>click here for instructions", @@ -10205,7 +10842,8 @@ "fr": "Obtenez votre <0>jeton GitLab ou <1>cliquez ici pour les instructions", "tr": "<0>GitLab jetonu alın veya <1>talimatlar için buraya tıklayın", "de": "Holen Sie sich Ihren <0>GitLab-Token oder <1>klicken Sie hier für Anweisungen", - "uk": "Get your <0>GitLab token or <1>click here for instructions" + "uk": "Get your <0>GitLab token or <1>click here for instructions", + "ca": "Obteniu el vostre <0>token de GitLab o <1>feu clic aquí per obtenir instruccions" }, "GITLAB$TOKEN_LINK_TEXT": { "en": "GitLab token", @@ -10221,7 +10859,8 @@ "fr": "jeton GitLab", "tr": "GitLab jetonu", "de": "GitLab-Token", - "uk": "GitLab токен" + "uk": "GitLab токен", + "ca": "token de GitLab" }, "GITLAB$INSTRUCTIONS_LINK_TEXT": { "en": "click here for instructions", @@ -10237,7 +10876,8 @@ "fr": "cliquez ici pour les instructions", "tr": "talimatlar için buraya tıklayın", "de": "klicken Sie hier für Anweisungen", - "uk": "натисніть тут, щоб отримати інструкції" + "uk": "натисніть тут, щоб отримати інструкції", + "ca": "feu clic aquí per obtenir instruccions" }, "GITLAB$WEBHOOK_MANAGER_TITLE": { "en": "Webhook Management", @@ -10253,7 +10893,8 @@ "fr": "Gestion des Webhooks", "tr": "Webhook Yönetimi", "de": "Webhook-Verwaltung", - "uk": "Керування Webhook" + "uk": "Керування Webhook", + "ca": "Gestió de Webhooks" }, "GITLAB$WEBHOOK_MANAGER_DESCRIPTION": { "en": "Manage webhooks for your GitLab projects and groups. Webhooks enable OpenHands to receive notifications from GitLab. Note: If a webhook is already installed, you must first delete it through the GitLab UI before reinstalling.", @@ -10269,7 +10910,8 @@ "fr": "Gérez les webhooks pour vos projets et groupes GitLab. Les webhooks permettent à OpenHands de recevoir des notifications de GitLab. Remarque : Si un webhook est déjà installé, vous devez d'abord le supprimer via l'interface GitLab avant de le réinstaller.", "tr": "GitLab projeleriniz ve gruplarınız için webhook'ları yönetin. Webhook'lar OpenHands'in GitLab'dan bildirim almasını sağlar. Not: Bir webhook zaten yüklüyse, yeniden yüklemeden önce GitLab arayüzü üzerinden silmeniz gerekir.", "de": "Verwalten Sie Webhooks für Ihre GitLab-Projekte und -Gruppen. Webhooks ermöglichen es OpenHands, Benachrichtigungen von GitLab zu empfangen. Hinweis: Wenn ein Webhook bereits installiert ist, müssen Sie ihn zuerst über die GitLab-Benutzeroberfläche löschen, bevor Sie ihn neu installieren.", - "uk": "Керуйте вебхуками для ваших проектів та груп GitLab. Вебхуки дозволяють OpenHands отримувати сповіщення від GitLab. Примітка: Якщо вебхук вже встановлено, ви повинні спочатку видалити його через інтерфейс GitLab перед повторним встановленням." + "uk": "Керуйте вебхуками для ваших проектів та груп GitLab. Вебхуки дозволяють OpenHands отримувати сповіщення від GitLab. Примітка: Якщо вебхук вже встановлено, ви повинні спочатку видалити його через інтерфейс GitLab перед повторним встановленням.", + "ca": "Gestioneu els webhooks per als vostres projectes i grups de GitLab. Els webhooks permeten a OpenHands rebre notificacions de GitLab. Nota: Si ja hi ha un webhook instal·lat, primer l'heu d'eliminar des de la interfície de GitLab abans de reinstal·lar-lo." }, "GITLAB$WEBHOOK_MANAGER_LOADING": { "en": "Loading resources...", @@ -10285,7 +10927,8 @@ "fr": "Chargement des ressources...", "tr": "Kaynaklar yükleniyor...", "de": "Ressourcen werden geladen...", - "uk": "Завантаження ресурсів..." + "uk": "Завантаження ресурсів...", + "ca": "Carregant recursos..." }, "GITLAB$WEBHOOK_MANAGER_ERROR": { "en": "Failed to load resources. Please try again.", @@ -10301,7 +10944,8 @@ "fr": "Échec du chargement des ressources. Veuillez réessayer.", "tr": "Kaynaklar yüklenemedi. Lütfen tekrar deneyin.", "de": "Ressourcen konnten nicht geladen werden. Bitte versuchen Sie es erneut.", - "uk": "Не вдалося завантажити ресурси. Будь ласка, спробуйте ще раз." + "uk": "Не вдалося завантажити ресурси. Будь ласка, спробуйте ще раз.", + "ca": "No s'han pogut carregar els recursos. Torneu-ho a intentar." }, "GITLAB$WEBHOOK_MANAGER_NO_RESOURCES": { "en": "No projects or groups found where you have admin access.", @@ -10317,7 +10961,8 @@ "fr": "Aucun projet ou groupe trouvé où vous avez un accès administrateur.", "tr": "Yönetici erişiminizin olduğu proje veya grup bulunamadı.", "de": "Keine Projekte oder Gruppen gefunden, auf die Sie Administratorzugriff haben.", - "uk": "Не знайдено проектів або груп, де ви маєте адміністраторський доступ." + "uk": "Не знайдено проектів або груп, де ви маєте адміністраторський доступ.", + "ca": "No s'han trobat projectes ni grups on tingueu accés d'administrador." }, "GITLAB$WEBHOOK_REINSTALL": { "en": "Reinstall", @@ -10333,7 +10978,8 @@ "fr": "Réinstaller", "tr": "Yeniden Yükle", "de": "Neu installieren", - "uk": "Перевстановити" + "uk": "Перевстановити", + "ca": "Reinstal·la" }, "GITLAB$WEBHOOK_REINSTALLING": { "en": "Reinstalling...", @@ -10349,7 +10995,8 @@ "fr": "Réinstallation...", "tr": "Yeniden yükleniyor...", "de": "Wird neu installiert...", - "uk": "Перевстановлення..." + "uk": "Перевстановлення...", + "ca": "Reinstal·lant..." }, "GITLAB$WEBHOOK_REINSTALL_SUCCESS": { "en": "Webhook reinstalled successfully", @@ -10365,7 +11012,8 @@ "fr": "Webhook réinstallé avec succès", "tr": "Webhook başarıyla yeniden yüklendi", "de": "Webhook erfolgreich neu installiert", - "uk": "Вебхук успішно перевстановлено" + "uk": "Вебхук успішно перевстановлено", + "ca": "Webhook reinstal·lat correctament" }, "GITLAB$WEBHOOK_COLUMN_RESOURCE": { "en": "Resource", @@ -10381,7 +11029,8 @@ "fr": "Ressource", "tr": "Kaynak", "de": "Ressource", - "uk": "Ресурс" + "uk": "Ресурс", + "ca": "Recurs" }, "GITLAB$WEBHOOK_COLUMN_TYPE": { "en": "Type", @@ -10397,7 +11046,8 @@ "fr": "Type", "tr": "Tür", "de": "Typ", - "uk": "Тип" + "uk": "Тип", + "ca": "Tipus" }, "GITLAB$WEBHOOK_COLUMN_STATUS": { "en": "Status", @@ -10413,7 +11063,8 @@ "fr": "Statut", "tr": "Durum", "de": "Status", - "uk": "Статус" + "uk": "Статус", + "ca": "Estat" }, "GITLAB$WEBHOOK_COLUMN_ACTION": { "en": "Action", @@ -10429,7 +11080,8 @@ "fr": "Action", "tr": "Eylem", "de": "Aktion", - "uk": "Дія" + "uk": "Дія", + "ca": "Acció" }, "GITLAB$WEBHOOK_STATUS_INSTALLED": { "en": "Installed", @@ -10445,7 +11097,8 @@ "fr": "Installé", "tr": "Yüklü", "de": "Installiert", - "uk": "Встановлено" + "uk": "Встановлено", + "ca": "Instal·lat" }, "GITLAB$WEBHOOK_STATUS_NOT_INSTALLED": { "en": "Not Installed", @@ -10461,7 +11114,8 @@ "fr": "Non installé", "tr": "Yüklü değil", "de": "Nicht installiert", - "uk": "Не встановлено" + "uk": "Не встановлено", + "ca": "No instal·lat" }, "GITLAB$WEBHOOK_STATUS_FAILED": { "en": "Failed", @@ -10477,7 +11131,8 @@ "fr": "Échoué", "tr": "Başarısız", "de": "Fehlgeschlagen", - "uk": "Помилка" + "uk": "Помилка", + "ca": "Error" }, "GITLAB$WEBHOOK_REINSTALL_FAILED": { "en": "Failed to reinstall webhook", @@ -10493,7 +11148,8 @@ "fr": "Échec de la réinstallation du webhook", "tr": "Webhook yeniden yüklenemedi", "de": "Webhook konnte nicht neu installiert werden", - "uk": "Не вдалося перевстановити вебхук" + "uk": "Не вдалося перевстановити вебхук", + "ca": "No s'ha pogut reinstal·lar el webhook" }, "BITBUCKET$TOKEN_LABEL": { "en": "Bitbucket Token", @@ -10509,7 +11165,8 @@ "fr": "Jeton Bitbucket", "tr": "Bitbucket Token", "de": "Bitbucket-Token", - "uk": "Токен Bitbucket" + "uk": "Токен Bitbucket", + "ca": "Token de Bitbucket" }, "BITBUCKET$HOST_LABEL": { "en": "Bitbucket Host", @@ -10525,7 +11182,8 @@ "fr": "Hôte Bitbucket", "tr": "Bitbucket Sunucu", "de": "Bitbucket-Host", - "uk": "Хост Bitbucket" + "uk": "Хост Bitbucket", + "ca": "Servidor de Bitbucket" }, "BITBUCKET$GET_TOKEN": { "en": "Get a Bitbucket token", @@ -10541,7 +11199,8 @@ "fr": "Obtenir un jeton Bitbucket", "tr": "Bitbucket token al", "de": "Bitbucket-Token erhalten", - "uk": "Отримати токен Bitbucket" + "uk": "Отримати токен Bitbucket", + "ca": "Obteniu un token de Bitbucket" }, "BITBUCKET$TOKEN_HELP_TEXT": { "en": "Get your <0>Bitbucket app password or <1>click here for instructions. Enter it in the format 'username:app_password'.", @@ -10557,7 +11216,8 @@ "fr": "Obtenez votre <0>mot de passe d'application Bitbucket ou <1>cliquez ici pour les instructions. Saisissez-le au format 'nom d'utilisateur:mot de passe d'application'.", "tr": "<0>Bitbucket uygulama şifrenizi alın veya <1>talimatlar için buraya tıklayın. 'kullanıcı adı:uygulama şifresi' formatında girin.", "de": "Holen Sie sich Ihr <0>Bitbucket App-Passwort oder <1>klicken Sie hier für Anweisungen. Geben Sie es im Format 'Benutzername:App-Passwort' ein.", - "uk": "Отримайте свій <0>пароль додатка Bitbucket або <1>натисніть тут, щоб отримати інструкції. Введіть його у форматі 'ім'я користувача:пароль додатка'." + "uk": "Отримайте свій <0>пароль додатка Bitbucket або <1>натисніть тут, щоб отримати інструкції. Введіть його у форматі 'ім'я користувача:пароль додатка'.", + "ca": "Obteniu la vostra <0>contrasenya d'aplicació de Bitbucket o <1>feu clic aquí per obtenir instruccions. Introduïu-la en el format 'username:app_password'." }, "BITBUCKET$TOKEN_LINK_TEXT": { "en": "Bitbucket app password", @@ -10573,7 +11233,8 @@ "fr": "mot de passe d'application Bitbucket", "tr": "Bitbucket uygulama şifresi", "de": "Bitbucket App-Passwort", - "uk": "пароль додатка Bitbucket" + "uk": "пароль додатка Bitbucket", + "ca": "contrasenya d'aplicació de Bitbucket" }, "BITBUCKET$INSTRUCTIONS_LINK_TEXT": { "en": "click here for instructions", @@ -10589,7 +11250,8 @@ "fr": "cliquez ici pour les instructions", "tr": "talimatlar için buraya tıklayın", "de": "klicken Sie hier für Anweisungen", - "uk": "натисніть тут, щоб отримати інструкції" + "uk": "натисніть тут, щоб отримати інструкції", + "ca": "feu clic aquí per obtenir instruccions" }, "BITBUCKET_DATA_CENTER$TOKEN_LABEL": { "en": "Bitbucket Data Center Token", @@ -10605,7 +11267,8 @@ "pt": "Token do Bitbucket Data Center", "es": "Token de Bitbucket Data Center", "tr": "Bitbucket Data Center Token", - "uk": "Токен Bitbucket Data Center" + "uk": "Токен Bitbucket Data Center", + "ca": "Token de Bitbucket Data Center" }, "BITBUCKET_DATA_CENTER$HOST_LABEL": { "en": "Bitbucket Data Center Host", @@ -10621,7 +11284,8 @@ "pt": "Host do Bitbucket Data Center", "es": "Host de Bitbucket Data Center", "tr": "Bitbucket Data Center Sunucu", - "uk": "Хост Bitbucket Data Center" + "uk": "Хост Bitbucket Data Center", + "ca": "Servidor de Bitbucket Data Center" }, "BITBUCKET_DATA_CENTER$TOKEN_HELP_TEXT": { "en": "Create an <0>HTTP access token in your Bitbucket Data Center instance with repository read/write and pull request read/write permissions. For personal access tokens, use the format 'username:token'. For project tokens, use the format 'x-token-auth:your-token'.", @@ -10637,7 +11301,8 @@ "pt": "Crie um <0>token de acesso HTTP na sua instância do Bitbucket Data Center com permissões de leitura/gravação de repositório e pull request. Para tokens de acesso pessoal, use o formato 'username:token'. Para tokens de projeto, use o formato 'x-token-auth:your-token'.", "es": "Cree un <0>token de acceso HTTP en su instancia de Bitbucket Data Center con permisos de lectura/escritura de repositorio y pull request. Para los tokens de acceso personal, use el formato 'username:token'. Para los tokens de proyecto, use el formato 'x-token-auth:your-token'.", "tr": "Bitbucket Data Center örneğinizde depo okuma/yazma ve pull request okuma/yazma izinlerine sahip bir <0>HTTP erişim jetonu oluşturun. Kişisel erişim jetonları için 'username:token' biçimini kullanın. Proje jetonları için 'x-token-auth:your-token' biçimini kullanın.", - "uk": "Створіть <0>HTTP-токен доступу у вашому екземплярі Bitbucket Data Center з правами читання/запису репозиторію та pull request. Для особистих токенів доступу використовуйте формат 'username:token'. Для токенів проекту використовуйте формат 'x-token-auth:your-token'." + "uk": "Створіть <0>HTTP-токен доступу у вашому екземплярі Bitbucket Data Center з правами читання/запису репозиторію та pull request. Для особистих токенів доступу використовуйте формат 'username:token'. Для токенів проекту використовуйте формат 'x-token-auth:your-token'.", + "ca": "Creeu un <0>token d'accés HTTP a la vostra instància de Bitbucket Data Center amb permisos de lectura/escriptura de repositori i lectura/escriptura de sol·licitud de canvis. Per als tokens d'accés personal, feu servir el format 'username:token'. Per als tokens de projecte, feu servir el format 'x-token-auth:your-token'." }, "GITLAB$OR_SEE": { "en": "or see the", @@ -10653,7 +11318,8 @@ "fr": "ou voir la", "tr": "veya bak", "de": "oder siehe", - "uk": "або перегляньте" + "uk": "або перегляньте", + "ca": "o consulteu el" }, "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_STOPPED": { "en": "Pause button pressed. Agent is stopped. The action has not been executed.", @@ -10669,7 +11335,8 @@ "fr": "Bouton pause enfoncé. L'agent est arrêté. L'action n'a pas été exécutée.", "tr": "Duraklat düğmesine basıldı. Ajan durduruldu. Eylem gerçekleştirilmedi.", "de": "Pausentaste gedrückt. Agent ist gestoppt. Die Aktion wurde nicht ausgeführt.", - "uk": "Натиснуто кнопку паузи. Агент зупинений. Дію не виконано." + "uk": "Натиснуто кнопку паузи. Агент зупинений. Дію не виконано.", + "ca": "S'ha premut el botó de pausa. L'agent s'ha aturat. L'acció no s'ha executat." }, "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED_ERROR": { "en": "The action has not been executed due to a runtime error. The runtime system may have crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.", @@ -10685,7 +11352,8 @@ "fr": "L'action n'a pas été exécutée en raison d'une erreur d'exécution. Le système d'exécution peut s'être planté et avoir redémarré en raison de contraintes de ressources. Tout état du système, dépendances ou variables d'environnement précédemment établis peuvent avoir été perdus.", "tr": "Çalışma zamanı hatası nedeniyle eylem yürütülmedi. Çalışma zamanı sistemi kaynak kısıtlamaları nedeniyle çökmüş ve yeniden başlamış olabilir. Daha önce kurulmuş olan herhangi bir sistem durumu, bağımlılıklar veya ortam değişkenleri kaybolmuş olabilir.", "de": "Die Aktion wurde aufgrund eines Laufzeitfehlers nicht ausgeführt. Das Laufzeitsystem ist möglicherweise aufgrund von Ressourcenbeschränkungen abgestürzt und neu gestartet worden. Alle zuvor eingerichteten Systemzustände, Abhängigkeiten oder Umgebungsvariablen sind möglicherweise verloren gegangen.", - "uk": "Дію не виконано через помилку виконання. Система виконання могла зазнати збою та перезапуститися через обмеження ресурсів. Можливо, було втрачено будь-який раніше встановлений стан системи, залежності або змінні середовища." + "uk": "Дію не виконано через помилку виконання. Система виконання могла зазнати збою та перезапуститися через обмеження ресурсів. Можливо, було втрачено будь-який раніше встановлений стан системи, залежності або змінні середовища.", + "ca": "L'acció no s'ha executat a causa d'un error de l'entorn d'execució. El sistema de l'entorn d'execució pot haver fallat i reiniciat a causa de restriccions de recursos. Qualsevol estat del sistema, dependències o variables d'entorn establerts anteriorment poden haver-se perdut." }, "DIFF_VIEWER$LOADING": { "en": "Loading changes...", @@ -10701,7 +11369,8 @@ "fr": "Chargement des modifications...", "tr": "Değişiklikler yükleniyor...", "de": "Änderungen werden geladen...", - "uk": "Завантаження змін..." + "uk": "Завантаження змін...", + "ca": "Carregant els canvis..." }, "DIFF_VIEWER$GETTING_LATEST_CHANGES": { "en": "Getting latest changes...", @@ -10717,7 +11386,8 @@ "fr": "Obtention des dernières modifications...", "tr": "Son değişiklikleri alıyor...", "de": "Aktuellste Änderungen abrufen...", - "uk": "Отримання останніх змін..." + "uk": "Отримання останніх змін...", + "ca": "Obtenint els darrers canvis..." }, "DIFF_VIEWER$NOT_A_GIT_REPO": { "en": "Your current workspace is not a git repository.", @@ -10733,7 +11403,8 @@ "fr": "Votre espace de travail actuel n'est pas un dépôt git.", "tr": "Mevcut çalışma alanınız bir git deposu değil.", "de": "Ihr aktueller Arbeitsbereich ist kein git-Repository.", - "uk": "Ваша поточна робоча область не є репозиторієм git." + "uk": "Ваша поточна робоча область не є репозиторієм git.", + "ca": "L'espai de treball actual no és un repositori git." }, "DIFF_VIEWER$ASK_OH": { "en": "Ask OpenHands to initialize a git repo to activate this UI.", @@ -10749,7 +11420,8 @@ "fr": "Demandez à OpenHands d'initialiser un dépôt git pour activer cette interface utilisateur.", "tr": "Bu UI'yi etkinleştirmek için OpenHands'tan bir git deposunu başlatmasını isteyin.", "de": "Bitten Sie OpenHands, ein git-Repository zu initialisieren, um diese Benutzeroberfläche zu aktivieren.", - "uk": "Попросіть OpenHands ініціалізувати git-репозиторій, щоб активувати цей інтерфейс користувача." + "uk": "Попросіть OpenHands ініціалізувати git-репозиторій, щоб активувати цей інтерфейс користувача.", + "ca": "Demaneu a OpenHands que inicialitzi un repositori git per activar aquesta interfície." }, "DIFF_VIEWER$NO_CHANGES": { "en": "OpenHands hasn't made any changes yet", @@ -10765,7 +11437,8 @@ "fr": "OpenHands n'a pas encore apporté de modifications", "tr": "OpenHands henüz herhangi bir değişiklik yapmadı", "de": "OpenHands hat noch keine Änderungen vorgenommen", - "uk": "OpenHands ще не вніс жодних змін" + "uk": "OpenHands ще не вніс жодних змін", + "ca": "L'OpenHands encara no ha fet cap canvi" }, "DIFF_VIEWER$WAITING_FOR_RUNTIME": { "en": "Waiting for runtime to start...", @@ -10781,7 +11454,8 @@ "fr": "En attente du démarrage de l'exécution...", "tr": "Çalışma zamanının başlamasını bekliyor...", "de": "Warten auf den Start der Laufzeit...", - "uk": "Очікування початку виконання..." + "uk": "Очікування початку виконання...", + "ca": "Esperant que l'entorn d'execució s'iniciï..." }, "SYSTEM_MESSAGE_MODAL$TITLE": { "en": "Agent Tools & Metadata", @@ -10797,7 +11471,8 @@ "pt": "Ferramentas e metadados do agente", "es": "Herramientas y metadatos del agente", "tr": "Ajan Araçları ve Meta Verileri", - "uk": "Інструменти та метадані агента" + "uk": "Інструменти та метадані агента", + "ca": "Eines i metadades de l'agent" }, "SYSTEM_MESSAGE_MODAL$AGENT_CLASS": { "en": "Agent Class:", @@ -10813,7 +11488,8 @@ "pt": "Classe do agente:", "es": "Clase de agente:", "tr": "Ajan Sınıfı:", - "uk": "Клас агента:" + "uk": "Клас агента:", + "ca": "Classe de l'agent:" }, "SYSTEM_MESSAGE_MODAL$OPENHANDS_VERSION": { "en": "OpenHands Version:", @@ -10829,7 +11505,8 @@ "pt": "Versão OpenHands:", "es": "Versión de OpenHands:", "tr": "OpenHands Sürümü:", - "uk": "OpenHands версія:" + "uk": "OpenHands версія:", + "ca": "Versió d'OpenHands:" }, "SYSTEM_MESSAGE_MODAL$SYSTEM_MESSAGE_TAB": { "en": "System Message", @@ -10845,7 +11522,8 @@ "pt": "Mensagem do sistema", "es": "Mensaje del sistema", "tr": "Sistem Mesajı", - "uk": "Системне повідомлення" + "uk": "Системне повідомлення", + "ca": "Missatge del sistema" }, "SYSTEM_MESSAGE_MODAL$TOOLS_TAB": { "en": "Available Tools", @@ -10861,7 +11539,8 @@ "pt": "Ferramentas disponíveis", "es": "Herramientas disponibles", "tr": "Kullanılabilir Araçlar", - "uk": "Доступні інструменти" + "uk": "Доступні інструменти", + "ca": "Eines disponibles" }, "SYSTEM_MESSAGE_MODAL$PARAMETERS": { "en": "Parameters:", @@ -10877,7 +11556,8 @@ "pt": "Parâmetros:", "es": "Parámetros:", "tr": "Parametreler:", - "uk": "Параметри:" + "uk": "Параметри:", + "ca": "Paràmetres:" }, "SYSTEM_MESSAGE_MODAL$NO_TOOLS": { "en": "No tools available for this agent", @@ -10893,7 +11573,8 @@ "pt": "Nenhuma ferramenta disponível para este agente", "es": "No hay herramientas disponibles para este agente", "tr": "Bu ajan için kullanılabilir araç yok", - "uk": "Для цього агента немає доступних інструментів" + "uk": "Для цього агента немає доступних інструментів", + "ca": "No hi ha eines disponibles per a aquest agent" }, "TOS$ACCEPT_TERMS_OF_SERVICE": { "en": "Accept Terms of Service", @@ -10909,7 +11590,8 @@ "uk": "Прийняти Умови надання послуг", "no": "Godta vilkår for tjenesten", "ar": "قبول شروط الخدمة", - "tr": "Hizmet Şartlarını Kabul Et" + "tr": "Hizmet Şartlarını Kabul Et", + "ca": "Accepta les condicions del servei" }, "TOS$ACCEPT_TERMS_DESCRIPTION": { "en": "Please review and accept our terms of service before continuing", @@ -10925,7 +11607,8 @@ "uk": "Будь ласка, ознайомтеся та прийміть наші умови надання послуг, перш ніж продовжити", "no": "Vennligst gjennomgå og godta våre vilkår for tjenesten før du fortsetter", "ar": "يرجى مراجعة وقبول شروط الخدمة الخاصة بنا قبل المتابعة", - "tr": "Devam etmeden önce lütfen hizmet şartlarımızı gözden geçirin ve kabul edin" + "tr": "Devam etmeden önce lütfen hizmet şartlarımızı gözden geçirin ve kabul edin", + "ca": "Reviseu i accepteu les condicions del servei abans de continuar" }, "TOS$CONTINUE": { "en": "Continue", @@ -10941,7 +11624,8 @@ "uk": "Продовжити", "no": "Fortsett", "ar": "متابعة", - "tr": "Devam Et" + "tr": "Devam Et", + "ca": "Continua" }, "TOS$ERROR_ACCEPTING": { "en": "Error accepting Terms of Service", @@ -10957,7 +11641,8 @@ "uk": "Помилка прийняття Умов обслуговування", "no": "Feil ved godkjenning av vilkår for tjenesten", "ar": "خطأ في قبول شروط الخدمة", - "tr": "Hizmet Şartlarını kabul ederken hata oluştu" + "tr": "Hizmet Şartlarını kabul ederken hata oluştu", + "ca": "Error en acceptar les condicions del servei" }, "TIPS$CUSTOMIZE_MICROAGENT": { "en": "You can customize OpenHands for your repo using an available microagent. Ask OpenHands to put a description of the repo, including how to run the code, into .openhands/microagents/repo.md.", @@ -10973,7 +11658,8 @@ "de": "Sie können OpenHands für Ihr Repository mit einem verfügbaren Mikroagenten anpassen. Bitten Sie OpenHands, eine Beschreibung des Repositorys, einschließlich der Ausführung des Codes, in .openhands/microagents/repo.md zu platzieren.", "fr": "Vous pouvez personnaliser OpenHands pour votre dépôt en utilisant un micro-agent disponible. Demandez à OpenHands de mettre une description du dépôt, y compris comment exécuter le code, dans .openhands/microagents/repo.md.", "tr": "Kullanılabilir bir mikro ajan kullanarak OpenHands'i deponuz için özelleştirebilirsiniz. OpenHands'ten deponun açıklamasını, kodun nasıl çalıştırılacağı dahil, .openhands/microagents/repo.md dosyasına koymasını isteyin.", - "uk": "Ви можете налаштувати OpenHands для свого репозиторію за допомогою доступного мікроагента. Попросіть OpenHands розмістити опис репозиторію, включаючи інформацію про те, як запустити код, у файлі .openhands/microagents/repo.md." + "uk": "Ви можете налаштувати OpenHands для свого репозиторію за допомогою доступного мікроагента. Попросіть OpenHands розмістити опис репозиторію, включаючи інформацію про те, як запустити код, у файлі .openhands/microagents/repo.md.", + "ca": "Podeu personalitzar OpenHands per al vostre repositori fent servir un microagent disponible. Demaneu a OpenHands que posi una descripció del repositori, incloent com executar el codi, a .openhands/microagents/repo.md." }, "CONVERSATION$NO_SKILLS": { "en": "No available skills found for this conversation.", @@ -10989,7 +11675,8 @@ "pt": "Nenhuma habilidade disponível encontrada para esta conversa.", "es": "No se encontraron habilidades disponibles para esta conversación.", "tr": "Bu sohbet için kullanılabilir yetenek bulunamadı.", - "uk": "У цій розмові не знайдено доступних навичок." + "uk": "У цій розмові не знайдено доступних навичок.", + "ca": "No s'han trobat habilitats disponibles per a aquesta conversa." }, "CONVERSATION$NO_HOOKS": { "en": "No hooks configured for this conversation.", @@ -11005,7 +11692,8 @@ "pt": "Nenhum hook configurado para esta conversa.", "es": "No hay hooks configurados para esta conversación.", "tr": "Bu sohbet için yapılandırılmış kanca yok.", - "uk": "Для цієї розмови не налаштовано хуків." + "uk": "Для цієї розмови не налаштовано хуків.", + "ca": "No hi ha hooks configurats per a aquesta conversa." }, "CONVERSATION$SHOW_HOOKS": { "en": "Show Available Hooks", @@ -11021,7 +11709,8 @@ "pt": "Mostrar hooks disponíveis", "es": "Mostrar hooks disponibles", "tr": "Kullanılabilir kancaları göster", - "uk": "Показати доступні хуки" + "uk": "Показати доступні хуки", + "ca": "Mostra els hooks disponibles" }, "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS": { "en": "Failed to fetch available microagents", @@ -11037,7 +11726,8 @@ "pt": "Falha ao buscar microagentes disponíveis", "es": "Error al obtener microagentes disponibles", "tr": "Kullanılabilir mikro ajanlar getirilemedi", - "uk": "Не вдалося отримати доступних мікроагентів" + "uk": "Не вдалося отримати доступних мікроагентів", + "ca": "No s'han pogut obtenir els microagents disponibles" }, "MICROAGENTS_MODAL$TITLE": { "en": "Available Microagents", @@ -11053,7 +11743,8 @@ "pt": "Microagentes disponíveis", "es": "Microagentes disponibles", "tr": "Kullanılabilir mikro ajanlar", - "uk": "Доступні мікроагенти" + "uk": "Доступні мікроагенти", + "ca": "Microagents disponibles" }, "SKILLS_MODAL$WARNING": { "en": "If you update the skills, you will need to stop the conversation and then click on the refresh button to see the changes.", @@ -11069,7 +11760,8 @@ "pt": "Se você atualizar as habilidades, precisará interromper a conversa e clicar no botão de atualizar para ver as mudanças.", "es": "Si actualizas las habilidades, deberás detener la conversación y luego hacer clic en el botón de actualizar para ver los cambios.", "tr": "Yetenekleri güncellerseniz, değişiklikleri görmek için sohbeti durdurmalı ve ardından yenile düğmesine tıklamalısınız.", - "uk": "Якщо ви оновите навички, вам потрібно буде зупинити розмову, а потім натиснути кнопку оновлення, щоб побачити зміни." + "uk": "Якщо ви оновите навички, вам потрібно буде зупинити розмову, а потім натиснути кнопку оновлення, щоб побачити зміни.", + "ca": "Si actualitzeu les habilitats, haureu d'aturar la conversa i fer clic al botó d'actualització per veure els canvis." }, "COMMON$TRIGGERS": { "en": "Triggers", @@ -11085,7 +11777,8 @@ "pt": "Gatilhos", "es": "Disparadores", "tr": "Tetikleyiciler", - "uk": "Тригери" + "uk": "Тригери", + "ca": "Disparadors" }, "MICROAGENTS_MODAL$INPUTS": { "en": "Inputs", @@ -11101,7 +11794,8 @@ "pt": "Entradas", "es": "Entradas", "tr": "Girdiler", - "uk": "Вхідні дані" + "uk": "Вхідні дані", + "ca": "Entrades" }, "MICROAGENTS_MODAL$TOOLS": { "en": "Tools", @@ -11117,7 +11811,8 @@ "pt": "Ferramentas", "es": "Herramientas", "tr": "Araçlar", - "uk": "Інструменти" + "uk": "Інструменти", + "ca": "Eines" }, "COMMON$CONTENT": { "en": "Content", @@ -11133,7 +11828,8 @@ "pt": "Conteúdo", "es": "Contenido", "tr": "İçerik", - "uk": "Вміст" + "uk": "Вміст", + "ca": "Contingut" }, "SKILLS_MODAL$NO_CONTENT": { "en": "Skill has no content", @@ -11149,7 +11845,8 @@ "pt": "A habilidade não possui conteúdo", "es": "La habilidad no tiene contenido", "tr": "Beceride içerik yok", - "uk": "У навички немає вмісту" + "uk": "У навички немає вмісту", + "ca": "L'habilitat no té contingut" }, "COMMON$FETCH_ERROR": { "en": "Failed to fetch skills. Please try again later.", @@ -11165,7 +11862,8 @@ "pt": "Falha ao buscar as habilidades. Por favor, tente novamente mais tarde.", "es": "No se pudieron obtener las habilidades. Por favor, inténtalo de nuevo más tarde.", "tr": "Beceriler alınamadı. Lütfen daha sonra tekrar deneyin.", - "uk": "Не вдалося отримати навички. Будь ласка, спробуйте пізніше." + "uk": "Не вдалося отримати навички. Будь ласка, спробуйте пізніше.", + "ca": "No s'han pogut obtenir les habilitats. Torneu-ho a intentar més tard." }, "TIPS$SETUP_SCRIPT": { "en": "You can add .openhands/setup.sh to your repository to automatically run a setup script every time you start an OpenHands conversation.", @@ -11181,7 +11879,8 @@ "fr": "Vous pouvez ajouter .openhands/setup.sh à votre dépôt pour exécuter automatiquement un script de configuration chaque fois que vous démarrez une conversation OpenHands.", "tr": "OpenHands konuşması başlattığınız her seferinde otomatik olarak bir kurulum betiği çalıştırmak için deponuza .openhands/setup.sh ekleyebilirsiniz.", "de": "Sie können .openhands/setup.sh zu Ihrem Repository hinzufügen, um jedes Mal, wenn Sie ein OpenHands-Gespräch starten, automatisch ein Setup-Skript auszuführen.", - "uk": "Ви можете додати .openhands/setup.sh до свого репозиторію, щоб автоматично запускати скрипт налаштування щоразу, коли ви починаєте розмову OpenHands." + "uk": "Ви можете додати .openhands/setup.sh до свого репозиторію, щоб автоматично запускати скрипт налаштування щоразу, коли ви починаєте розмову OpenHands.", + "ca": "Podeu afegir .openhands/setup.sh al vostre repositori per executar automàticament un script de configuració cada vegada que inicieu una conversa d'OpenHands." }, "TIPS$VSCODE_INSTANCE": { "en": "Every OpenHands conversation comes with a VS Code instance, where you can interact with the development environment.", @@ -11197,7 +11896,8 @@ "fr": "Chaque conversation OpenHands est accompagnée d'une instance VS Code, où vous pouvez interagir avec l'environnement de développement.", "tr": "Her OpenHands konuşması, geliştirme ortamıyla etkileşimde bulunabileceğiniz bir VS Code örneği ile birlikte gelir.", "de": "Jedes OpenHands-Gespräch wird mit einer VS Code-Instanz geliefert, in der Sie mit der Entwicklungsumgebung interagieren können.", - "uk": "Кожна розмова OpenHands постачається з екземпляром VS Code, де ви можете взаємодіяти із середовищем розробки." + "uk": "Кожна розмова OpenHands постачається з екземпляром VS Code, де ви можете взаємодіяти із середовищем розробки.", + "ca": "Cada conversa d'OpenHands inclou una instància de VS Code, on podeu interactuar amb l'entorn de desenvolupament." }, "TIPS$SAVE_WORK": { "en": "Be sure to regularly save your work, either by pushing to GitHub or by downloading your files via VS Code.", @@ -11213,7 +11913,8 @@ "fr": "Assurez-vous de sauvegarder régulièrement votre travail, soit en le poussant vers GitHub, soit en téléchargeant vos fichiers via VS Code.", "tr": "GitHub'a göndererek veya VS Code aracılığıyla dosyalarınızı indirerek çalışmalarınızı düzenli olarak kaydettiğinizden emin olun.", "de": "Stellen Sie sicher, dass Sie Ihre Arbeit regelmäßig speichern, entweder durch Pushen zu GitHub oder durch Herunterladen Ihrer Dateien über VS Code.", - "uk": "Обов’язково регулярно зберігайте свою роботу, або завантажуючи її на GitHub, або завантажуючи файли через VS Code." + "uk": "Обов’язково регулярно зберігайте свою роботу, або завантажуючи її на GitHub, або завантажуючи файли через VS Code.", + "ca": "Assegureu-vos de desar el treball regularment, ja sigui publicant-lo a GitHub o descarregant els fitxers a través de VS Code." }, "TIPS$SPECIFY_FILES": { "en": "When possible, include the names of files or functions OpenHands should focus on. This can help OpenHands work faster, save money, and improve accuracy.", @@ -11229,7 +11930,8 @@ "fr": "Lorsque c'est possible, incluez les noms des fichiers ou des fonctions sur lesquels OpenHands devrait se concentrer. Cela peut aider OpenHands à travailler plus rapidement, à économiser de l'argent et à améliorer la précision.", "tr": "Mümkün olduğunda, OpenHands'in odaklanması gereken dosya veya fonksiyon isimlerini dahil edin. Bu, OpenHands'in daha hızlı çalışmasına, para tasarrufu sağlamasına ve doğruluğu artırmasına yardımcı olabilir.", "de": "Wenn möglich, geben Sie die Namen der Dateien oder Funktionen an, auf die sich OpenHands konzentrieren soll. Dies kann OpenHands helfen, schneller zu arbeiten, Geld zu sparen und die Genauigkeit zu verbessern.", - "uk": "Коли це можливо, вказуйте назви файлів або функцій, на яких має зосередитися OpenHands. Це може допомогти OpenHands працювати швидше, заощадити гроші та підвищити точність." + "uk": "Коли це можливо, вказуйте назви файлів або функцій, на яких має зосередитися OpenHands. Це може допомогти OpenHands працювати швидше, заощадити гроші та підвищити точність.", + "ca": "Quan sigui possible, incloeu els noms dels fitxers o funcions en els quals OpenHands s'ha de centrar. Això pot ajudar OpenHands a treballar més ràpid, estalviar diners i millorar la precisió." }, "TIPS$HEADLESS_MODE": { "en": "You can run OpenHands in headless mode to create automations, like responding to 500 errors by automatically creating a fix.", @@ -11245,7 +11947,8 @@ "fr": "Vous pouvez exécuter OpenHands en mode headless pour créer des automatisations, comme répondre aux erreurs 500 en créant automatiquement un correctif.", "tr": "OpenHands'i başsız modda çalıştırarak, 500 hatalarına otomatik olarak düzeltme oluşturarak yanıt vermek gibi otomasyonlar oluşturabilirsiniz.", "de": "Sie können OpenHands im Headless-Modus ausführen, um Automatisierungen zu erstellen, wie z.B. das Reagieren auf 500-Fehler durch automatisches Erstellen einer Lösung.", - "uk": "Ви можете запускати OpenHands в headless режимі для створення автоматизації, наприклад, реагування на 500 помилок шляхом автоматичного створення виправлення." + "uk": "Ви можете запускати OpenHands в headless режимі для створення автоматизації, наприклад, реагування на 500 помилок шляхом автоматичного створення виправлення.", + "ca": "Podeu executar OpenHands en mode sense cap per crear automatitzacions, com respondre a errors 500 creant automàticament una solució." }, "TIPS$CLI_MODE": { "en": "You can run OpenHands as a CLI, similar to Claude Code.", @@ -11261,7 +11964,8 @@ "fr": "Vous pouvez exécuter OpenHands en tant que CLI, similaire à Claude Code.", "tr": "OpenHands'i Claude Code'a benzer şekilde bir CLI olarak çalıştırabilirsiniz.", "de": "Sie können OpenHands als CLI ausführen, ähnlich wie Claude Code.", - "uk": "Ви можете запускати OpenHands як CLI, подібно до Claude Code." + "uk": "Ви можете запускати OpenHands як CLI, подібно до Claude Code.", + "ca": "Podeu executar OpenHands com a CLI, similar a Claude Code." }, "TIPS$GITHUB_HOOK": { "en": "OpenHands Cloud offers a GitHub hook, so you can say \"@openhands fix the merge conflicts\" or \"@openhands fix the feedback on this PR\" right inside the GitHub UI.", @@ -11277,7 +11981,8 @@ "fr": "OpenHands Cloud propose un hook GitHub, vous pouvez donc dire \"@openhands fix the merge conflicts\" ou \"@openhands fix the feedback on this PR\" directement dans l'interface GitHub.", "tr": "OpenHands Cloud, GitHub kancası sunar, böylece GitHub arayüzünde doğrudan \"@openhands birleştirme çakışmalarını düzelt\" veya \"@openhands bu PR'daki geri bildirimi düzelt\" diyebilirsiniz.", "de": "OpenHands Cloud bietet einen GitHub-Hook, sodass Sie \"@openhands fix the merge conflicts\" oder \"@openhands fix the feedback on this PR\" direkt in der GitHub-Benutzeroberfläche sagen können.", - "uk": "OpenHands Cloud пропонує хук GitHub, тож ви можете сказати «@openhands виправити конфлікти злиття» або «@openhands виправити відгук про цей PR» прямо в інтерфейсі GitHub." + "uk": "OpenHands Cloud пропонує хук GitHub, тож ви можете сказати «@openhands виправити конфлікти злиття» або «@openhands виправити відгук про цей PR» прямо в інтерфейсі GitHub.", + "ca": "OpenHands Cloud ofereix un hook de GitHub, de manera que podeu dir \"@openhands corregeix els conflictes de fusió\" o \"@openhands corregeix els comentaris d'aquesta PR\" directament dins de la interfície de GitHub." }, "TIPS$BLOG_SIGNUP": { "en": "Sign up for the OpenHands Blog to hear about new features and the latest releases.", @@ -11293,7 +11998,8 @@ "fr": "Inscrivez-vous au blog OpenHands pour connaître les nouvelles fonctionnalités et les dernières versions.", "tr": "Yeni özellikler ve en son sürümler hakkında bilgi almak için OpenHands Blog'a kaydolun.", "de": "Melden Sie sich für den OpenHands Blog an, um über neue Funktionen und die neuesten Versionen informiert zu werden.", - "uk": "Підпишіться на блог OpenHands, щоб дізнаватися про нові функції та останні релізи." + "uk": "Підпишіться на блог OpenHands, щоб дізнаватися про нові функції та останні релізи.", + "ca": "Subscriviu-vos al blog d'OpenHands per assabentar-vos de les noves funcions i les darreres versions." }, "TIPS$API_USAGE": { "en": "OpenHands has an API! Create OpenHands conversations with simple cURL command.", @@ -11309,7 +12015,8 @@ "fr": "OpenHands a une API ! Créez des conversations OpenHands avec une simple commande cURL.", "tr": "OpenHands'in bir API'si var! Basit bir cURL komutuyla OpenHands konuşmaları oluşturun.", "de": "OpenHands hat eine API! Erstellen Sie OpenHands-Gespräche mit einem einfachen cURL-Befehl.", - "uk": "OpenHands має API! Створюйте розмови OpenHands за допомогою простої команди cURL." + "uk": "OpenHands має API! Створюйте розмови OpenHands за допомогою простої команди cURL.", + "ca": "OpenHands té una API! Creeu converses d'OpenHands amb una senzilla comanda cURL." }, "TIPS$LEARN_MORE": { "en": "Learn more", @@ -11325,7 +12032,8 @@ "fr": "En savoir plus", "tr": "Daha fazla bilgi", "de": "Mehr erfahren", - "uk": "Дізнатися більше" + "uk": "Дізнатися більше", + "ca": "Aprèn-ne més" }, "TIPS$PROTIP": { "en": "Protip", @@ -11341,7 +12049,8 @@ "fr": "Astuce pro", "tr": "Uzman ipucu", "de": "Profi-Tipp", - "uk": "Порада професіонала" + "uk": "Порада професіонала", + "ca": "Consell" }, "FEEDBACK$SUBMITTING_LABEL": { "en": "Submitting...", @@ -11357,7 +12066,8 @@ "fr": "Envoi...", "tr": "Gönderiliyor...", "de": "Senden...", - "uk": "Відправляємо..." + "uk": "Відправляємо...", + "ca": "Enviant..." }, "FEEDBACK$SUBMITTING_MESSAGE": { "en": "Submitting feedback, please wait...", @@ -11373,7 +12083,8 @@ "fr": "Envoi de commentaires, veuillez patienter...", "tr": "Geri bildirim gönderiliyor, lütfen bekleyin...", "de": "Feedback senden, bitte warten...", - "uk": "Відправляємо відгук, будь ласка, почекайте..." + "uk": "Відправляємо відгук, будь ласка, почекайте...", + "ca": "Enviant els comentaris, espereu..." }, "SETTINGS$NAV_ADD_TEAM_MEMBERS": { "en": "Add Team Members", @@ -11389,7 +12100,8 @@ "fr": "Ajouter des membres de l'équipe", "tr": "Takım üyeleri ekle", "de": "Teammitglieder hinzufügen", - "uk": "Додати учасників команди" + "uk": "Додати учасників команди", + "ca": "Afegeix membres de l'equip" }, "SETTINGS$NAV_USER": { "en": "User", @@ -11405,7 +12117,8 @@ "fr": "Utilisateur", "tr": "Kullanıcı", "de": "Benutzer", - "uk": "Користувач" + "uk": "Користувач", + "ca": "Usuari" }, "SETTINGS$USER_TITLE": { "en": "User Information", @@ -11421,7 +12134,8 @@ "fr": "Informations utilisateur", "tr": "Kullanıcı Bilgileri", "de": "Benutzerinformationen", - "uk": "Інформація про користувача" + "uk": "Інформація про користувача", + "ca": "Informació de l'usuari" }, "SETTINGS$USER_EMAIL": { "en": "Email", @@ -11437,7 +12151,8 @@ "fr": "Email", "tr": "E-posta", "de": "E-Mail", - "uk": "Електронна пошта" + "uk": "Електронна пошта", + "ca": "Correu electrònic" }, "SETTINGS$USER_EMAIL_LOADING": { "en": "Loading...", @@ -11453,7 +12168,8 @@ "fr": "Chargement...", "tr": "Yükleniyor...", "de": "Wird geladen...", - "uk": "Завантаження..." + "uk": "Завантаження...", + "ca": "Carregant..." }, "SETTINGS$SAVE": { "en": "Save", @@ -11469,7 +12185,8 @@ "fr": "Enregistrer", "tr": "Kaydet", "de": "Speichern", - "uk": "Зберегти" + "uk": "Зберегти", + "ca": "Desa" }, "SETTINGS$EMAIL_SAVED_SUCCESSFULLY": { "en": "Email saved successfully", @@ -11485,7 +12202,8 @@ "fr": "Email enregistré avec succès", "tr": "E-posta başarıyla kaydedildi", "de": "E-Mail erfolgreich gespeichert", - "uk": "Електронну пошту успішно збережено" + "uk": "Електронну пошту успішно збережено", + "ca": "Correu electrònic desat correctament" }, "SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY": { "en": "Your email has been verified successfully!", @@ -11501,7 +12219,8 @@ "fr": "Votre email a été vérifié avec succès !", "tr": "E-postanız başarıyla doğrulandı!", "de": "Ihre E-Mail wurde erfolgreich verifiziert!", - "uk": "Вашу електронну пошту успішно підтверджено!" + "uk": "Вашу електронну пошту успішно підтверджено!", + "ca": "El vostre correu electrònic s'ha verificat correctament!" }, "SETTINGS$FAILED_TO_SAVE_EMAIL": { "en": "Failed to save email", @@ -11517,7 +12236,8 @@ "fr": "Échec de l'enregistrement de l'email", "tr": "E-posta kaydedilemedi", "de": "E-Mail konnte nicht gespeichert werden", - "uk": "Не вдалося зберегти електронну пошту" + "uk": "Не вдалося зберегти електронну пошту", + "ca": "No s'ha pogut desar el correu electrònic" }, "SETTINGS$SENDING": { "en": "Sending", @@ -11533,7 +12253,8 @@ "fr": "Envoi en cours", "tr": "Gönderiliyor", "de": "Wird gesendet", - "uk": "Надсилання" + "uk": "Надсилання", + "ca": "Enviant" }, "SETTINGS$VERIFICATION_EMAIL_SENT": { "en": "Verification email sent", @@ -11549,7 +12270,8 @@ "fr": "Email de vérification envoyé", "tr": "Doğrulama e-postası gönderildi", "de": "Bestätigungs-E-Mail gesendet", - "uk": "Лист підтвердження надіслано" + "uk": "Лист підтвердження надіслано", + "ca": "Correu electrònic de verificació enviat" }, "SETTINGS$EMAIL_VERIFICATION_REQUIRED": { "en": "You must verify your email address before using All Hands", @@ -11565,7 +12287,8 @@ "fr": "Vous devez vérifier votre adresse e-mail avant d'utiliser All Hands", "tr": "All Hands'i kullanmadan önce e-posta adresinizi doğrulamanız gerekiyor", "de": "Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie All Hands verwenden können", - "uk": "Ви повинні підтвердити свою електронну адресу перед використанням All Hands" + "uk": "Ви повинні підтвердити свою електронну адресу перед використанням All Hands", + "ca": "Heu de verificar la vostra adreça de correu electrònic abans de fer servir All Hands" }, "SETTINGS$INVALID_EMAIL_FORMAT": { "en": "Please enter a valid email address", @@ -11581,7 +12304,8 @@ "fr": "Veuillez entrer une adresse e-mail valide", "tr": "Lütfen geçerli bir e-posta adresi girin", "de": "Bitte geben Sie eine gültige E-Mail-Adresse ein", - "uk": "Будь ласка, введіть дійсну електронну адресу" + "uk": "Будь ласка, введіть дійсну електронну адресу", + "ca": "Introduïu una adreça de correu electrònic vàlida" }, "SETTINGS$EMAIL_VERIFICATION_RESTRICTION_MESSAGE": { "en": "Your access is limited until your email is verified. You can only access this settings page.", @@ -11597,7 +12321,8 @@ "fr": "Votre accès est limité jusqu'à ce que votre e-mail soit vérifié. Vous ne pouvez accéder qu'à cette page de paramètres.", "tr": "E-postanız doğrulanana kadar erişiminiz sınırlıdır. Yalnızca bu ayarlar sayfasına erişebilirsiniz.", "de": "Ihr Zugriff ist eingeschränkt, bis Ihre E-Mail-Adresse bestätigt wurde. Sie können nur auf diese Einstellungsseite zugreifen.", - "uk": "Ваш доступ обмежений, доки ваша електронна пошта не буде підтверджена. Ви можете отримати доступ лише до цієї сторінки налаштувань." + "uk": "Ваш доступ обмежений, доки ваша електронна пошта не буде підтверджена. Ви можете отримати доступ лише до цієї сторінки налаштувань.", + "ca": "El vostre accés és limitat fins que el correu electrònic sigui verificat. Només podeu accedir a aquesta pàgina de configuració." }, "SETTINGS$RESEND_VERIFICATION": { "en": "Resend verification", @@ -11613,7 +12338,8 @@ "fr": "Renvoyer la vérification", "tr": "Doğrulamayı yeniden gönder", "de": "Bestätigung erneut senden", - "uk": "Надіслати підтвердження повторно" + "uk": "Надіслати підтвердження повторно", + "ca": "Reenvieu la verificació" }, "SETTINGS$FAILED_TO_RESEND_VERIFICATION": { "en": "Failed to resend verification email", @@ -11629,7 +12355,8 @@ "fr": "Échec du renvoi de l'email de vérification", "tr": "Doğrulama e-postası yeniden gönderilemedi", "de": "Bestätigungs-E-Mail konnte nicht erneut gesendet werden", - "uk": "Не вдалося повторно надіслати лист підтвердження" + "uk": "Не вдалося повторно надіслати лист підтвердження", + "ca": "No s'ha pogut reenviar el correu electrònic de verificació" }, "FEEDBACK$RATE_AGENT_PERFORMANCE": { "en": "Rate the agent's performance:", @@ -11645,7 +12372,8 @@ "fr": "Évaluez la performance de l'agent :", "tr": "Ajanın performansını değerlendirin:", "de": "Bewerten Sie die Leistung des Agenten:", - "uk": "Оцініть продуктивність агента:" + "uk": "Оцініть продуктивність агента:", + "ca": "Valoreu el rendiment de l'agent:" }, "FEEDBACK$SELECT_REASON": { "en": "Select a reason (optional):", @@ -11661,7 +12389,8 @@ "fr": "Sélectionnez une raison (facultatif) :", "tr": "Bir neden seçin (isteğe bağlı):", "de": "Wählen Sie einen Grund (optional):", - "uk": "Виберіть причину (необов'язково):" + "uk": "Виберіть причину (необов'язково):", + "ca": "Seleccioneu un motiu (opcional):" }, "FEEDBACK$SELECT_REASON_COUNTDOWN": { "en": "Auto-submitting in {{countdown}} seconds...", @@ -11677,7 +12406,8 @@ "fr": "Envoi automatique dans {{countdown}} secondes...", "tr": "{{countdown}} saniye içinde otomatik gönderilecek...", "de": "Automatische Übermittlung in {{countdown}} Sekunden...", - "uk": "Автоматична відправка через {{countdown}} секунд..." + "uk": "Автоматична відправка через {{countdown}} секунд...", + "ca": "S'enviarà automàticament en {{countdown}} segons..." }, "FEEDBACK$REASON_MISUNDERSTOOD_INSTRUCTION": { "en": "The agent misunderstood my instruction", @@ -11693,7 +12423,8 @@ "fr": "L'agent a mal compris mes instructions", "tr": "Ajan talimatlarımı yanlış anladı", "de": "Der Agent hat meine Anweisungen missverstanden", - "uk": "Агент неправильно зрозумів мої інструкції" + "uk": "Агент неправильно зрозумів мої інструкції", + "ca": "L'agent no ha entès les meves instruccions" }, "FEEDBACK$REASON_FORGOT_CONTEXT": { "en": "The agent forgot about the earlier context", @@ -11709,7 +12440,8 @@ "fr": "L'agent a oublié le contexte précédent", "tr": "Ajan önceki bağlamı unuttu", "de": "Der Agent hat den früheren Kontext vergessen", - "uk": "Агент забув про попередній контекст" + "uk": "Агент забув про попередній контекст", + "ca": "L'agent ha oblidat el context anterior" }, "FEEDBACK$REASON_UNNECESSARY_CHANGES": { "en": "The agent made unnecessary changes", @@ -11725,7 +12457,8 @@ "fr": "L'agent a apporté des modifications inutiles", "tr": "Ajan gereksiz değişiklikler yaptı", "de": "Der Agent hat unnötige Änderungen vorgenommen", - "uk": "Агент зробив непотрібні зміни" + "uk": "Агент зробив непотрібні зміни", + "ca": "L'agent ha fet canvis innecessaris" }, "FEEDBACK$REASON_SHOULD_ASK_FIRST": { "en": "The agent should've asked me first before doing it!", @@ -11741,7 +12474,8 @@ "fr": "L'agent aurait dû me demander d'abord avant de le faire !", "tr": "Ajan bunu yapmadan önce bana sormalıydı!", "de": "Der Agent hätte mich vorher fragen sollen!", - "uk": "Агент повинен був спочатку запитати мене, перш ніж це робити!" + "uk": "Агент повинен був спочатку запитати мене, перш ніж це робити!", + "ca": "L'agent hauria d'haver-me preguntat primer abans de fer-ho!" }, "FEEDBACK$REASON_DIDNT_FINISH_JOB": { "en": "The agent didn't finish the job", @@ -11757,7 +12491,8 @@ "fr": "L'agent n'a pas terminé le travail", "tr": "Ajan işi bitirmedi", "de": "Der Agent hat die Aufgabe nicht beendet", - "uk": "Агент не завершив роботу" + "uk": "Агент не завершив роботу", + "ca": "L'agent no ha acabat la feina" }, "FEEDBACK$REASON_OTHER": { "en": "Other", @@ -11773,7 +12508,8 @@ "fr": "Autre", "tr": "Diğer", "de": "Andere", - "uk": "Інше" + "uk": "Інше", + "ca": "Altre" }, "FEEDBACK$THANK_YOU_FOR_FEEDBACK": { "en": "Thank you for your feedback! This will help us improve OpenHands going forward.", @@ -11789,7 +12525,8 @@ "fr": "Merci pour votre retour ! Cela nous aidera à améliorer OpenHands à l'avenir.", "tr": "Geri bildiriminiz için teşekkürler! Bu, OpenHands'i ileride geliştirmemize yardımcı olacak.", "de": "Vielen Dank für Ihr Feedback! Das hilft uns, OpenHands in Zukunft zu verbessern.", - "uk": "Дякуємо за ваш відгук! Це допоможе нам покращити OpenHands у майбутньому." + "uk": "Дякуємо за ваш відгук! Це допоможе нам покращити OpenHands у майбутньому.", + "ca": "Gràcies pels vostres comentaris! Això ens ajudarà a millorar OpenHands en el futur." }, "FEEDBACK$FAILED_TO_SUBMIT": { "en": "Failed to submit feedback", @@ -11805,7 +12542,8 @@ "fr": "Échec de l'envoi des commentaires", "tr": "Geri bildirim gönderilemedi", "de": "Feedback konnte nicht gesendet werden", - "uk": "Не вдалося надіслати відгук" + "uk": "Не вдалося надіслати відгук", + "ca": "No s'han pogut enviar els comentaris" }, "HOME$ADD_GITHUB_REPOS": { "en": "+ Add GitHub Repos", @@ -11821,7 +12559,8 @@ "fr": "+ Ajouter des dépôts GitHub", "tr": "+ GitHub depoları ekle", "de": "+ GitHub-Repositories hinzufügen", - "uk": "+ Додати репозиторії GitHub" + "uk": "+ Додати репозиторії GitHub", + "ca": "+ Afegeix repositoris de GitHub" }, "REPOSITORY$SELECT_BRANCH": { "en": "Select a branch", @@ -11837,7 +12576,8 @@ "fr": "Sélectionner une branche", "tr": "Bir dal seç", "de": "Einen Branch auswählen", - "uk": "Вибрати гілку" + "uk": "Вибрати гілку", + "ca": "Selecciona una branca" }, "REPOSITORY$SELECT_REPO": { "en": "Select a repo", @@ -11853,7 +12593,8 @@ "fr": "Sélectionner un dépôt", "tr": "Bir depo seç", "de": "Ein Repository auswählen", - "uk": "Вибрати репозиторій" + "uk": "Вибрати репозиторій", + "ca": "Selecciona un repositori" }, "TASKS$NO_GIT_PROVIDERS_TITLE": { "en": "Connect a Git provider", @@ -11869,7 +12610,8 @@ "pt": "Conectar um provedor Git", "es": "Conectar un proveedor Git", "tr": "Git sağlayıcısını bağla", - "uk": "Підключити постачальник Git" + "uk": "Підключити постачальник Git", + "ca": "Connecta un proveïdor de Git" }, "TASKS$NO_GIT_PROVIDERS_DESCRIPTION": { "en": "Connect a Git provider to see suggested tasks from your repositories.", @@ -11885,7 +12627,8 @@ "pt": "Conecte um provedor Git para ver tarefas sugeridas dos seus repositórios.", "es": "Conecta un proveedor Git para ver tareas sugeridas de tus repositorios.", "tr": "Depolarınızdan önerilen görevleri görmek için bir Git sağlayıcısı bağlayın.", - "uk": "Підключіть постачальник Git, щоб бачити запропоновані завдання з ваших репозиторіїв." + "uk": "Підключіть постачальник Git, щоб бачити запропоновані завдання з ваших репозиторіїв.", + "ca": "Connecteu un proveïdor de Git per veure les tasques suggerides dels vostres repositoris." }, "TASKS$NO_GIT_PROVIDERS_CTA": { "en": "Go to Integrations", @@ -11901,7 +12644,8 @@ "pt": "Ir para integrações", "es": "Ir a integraciones", "tr": "Entegrasyonlara git", - "uk": "Перейти до інтеграцій" + "uk": "Перейти до інтеграцій", + "ca": "Vés a Integracions" }, "TASKS$SUGGESTED_TASKS": { "en": "Suggested Tasks", @@ -11917,7 +12661,8 @@ "fr": "Tâches suggérées", "tr": "Önerilen görevler", "de": "Vorgeschlagene Aufgaben", - "uk": "Запропоновані завдання" + "uk": "Запропоновані завдання", + "ca": "Tasques suggerides" }, "TASKS$NO_TASKS_AVAILABLE": { "en": "No tasks available", @@ -11933,7 +12678,8 @@ "fr": "Aucune tâche disponible", "tr": "Mevcut görev yok", "de": "Keine Aufgaben verfügbar", - "uk": "Немає доступних завдань" + "uk": "Немає доступних завдань", + "ca": "No hi ha tasques disponibles" }, "TASKS$TASK_SUGGESTIONS_INFO": { "en": "Task suggestions information", @@ -11949,7 +12695,8 @@ "fr": "Informations sur les suggestions de tâches", "tr": "Görev önerisi bilgileri", "de": "Aufgabenvorschlag-Informationen", - "uk": "Інформація про пропозиції завдань" + "uk": "Інформація про пропозиції завдань", + "ca": "Informació sobre els suggeriments de tasques" }, "TASKS$TASK_SUGGESTIONS_TOOLTIP": { "en": "These are AI-curated task suggestions to help you get started with common development activities and best practices for your repository.", @@ -11965,7 +12712,8 @@ "fr": "Ce sont des suggestions de tâches curées par l'IA pour vous aider à commencer avec les activités de développement courantes et les meilleures pratiques pour votre dépôt.", "tr": "Bunlar, deponuz için yaygın geliştirme faaliyetleri ve en iyi uygulamalarla başlamanıza yardımcı olmak için AI tarafından düzenlenmiş görev önerileridir.", "de": "Dies sind KI-kuratierte Aufgabenvorschläge, die Ihnen helfen, mit gängigen Entwicklungsaktivitäten und bewährten Praktiken für Ihr Repository zu beginnen.", - "uk": "Це AI-курировані пропозиції завдань, які допоможуть вам розпочати з поширеними діяльностями розробки та найкращими практиками для вашого репозиторію." + "uk": "Це AI-курировані пропозиції завдань, які допоможуть вам розпочати з поширеними діяльностями розробки та найкращими практиками для вашого репозиторію.", + "ca": "Aquests són suggeriments de tasques seleccionats per IA per ajudar-vos a començar amb activitats de desenvolupament habituals i les millors pràctiques per al vostre repositori." }, "PAYMENT$SPECIFY_AMOUNT_USD": { "en": "Specify an amount in USD to add - min $10", @@ -11981,7 +12729,8 @@ "fr": "Spécifiez un montant en USD à ajouter - min 10 $", "tr": "Eklenecek USD tutarını belirtin - min $10", "de": "Geben Sie einen USD-Betrag zum Hinzufügen an - min $10", - "uk": "Вкажіть суму в доларах США для додавання - мін $10" + "uk": "Вкажіть суму в доларах США для додавання - мін $10", + "ca": "Especifiqueu un import en USD per afegir - mínim 10 $" }, "PAYMENT$ERROR_INVALID_NUMBER": { "en": "Please enter a valid number", @@ -11997,7 +12746,8 @@ "fr": "Veuillez entrer un nombre valide", "tr": "Lütfen geçerli bir sayı girin", "de": "Bitte geben Sie eine gültige Zahl ein", - "uk": "Будь ласка, введіть дійсне число" + "uk": "Будь ласка, введіть дійсне число", + "ca": "Introduïu un número vàlid" }, "PAYMENT$ERROR_NEGATIVE_AMOUNT": { "en": "Amount cannot be negative", @@ -12013,7 +12763,8 @@ "fr": "Le montant ne peut pas être négatif", "tr": "Tutar negatif olamaz", "de": "Der Betrag darf nicht negativ sein", - "uk": "Сума не може бути від'ємною" + "uk": "Сума не може бути від'ємною", + "ca": "L'import no pot ser negatiu" }, "PAYMENT$ERROR_MINIMUM_AMOUNT": { "en": "Minimum amount is $10", @@ -12029,7 +12780,8 @@ "fr": "Le montant minimum est de 10 $", "tr": "Minimum tutar $10'dur", "de": "Der Mindestbetrag beträgt 10 $", - "uk": "Мінімальна сума становить $10" + "uk": "Мінімальна сума становить $10", + "ca": "L'import mínim és de $10" }, "PAYMENT$ERROR_MAXIMUM_AMOUNT": { "en": "Maximum amount is $25,000", @@ -12045,7 +12797,8 @@ "fr": "Le montant maximum est de 25 000 $", "tr": "Maksimum tutar $25,000'dur", "de": "Der Höchstbetrag beträgt 25.000 $", - "uk": "Максимальна сума становить $25,000" + "uk": "Максимальна сума становить $25,000", + "ca": "L'import màxim és de $25.000" }, "PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER": { "en": "Amount must be a whole number", @@ -12061,7 +12814,8 @@ "fr": "Le montant doit être un nombre entier", "tr": "Tutar tam sayı olmalıdır", "de": "Der Betrag muss eine ganze Zahl sein", - "uk": "Сума повинна бути цілим числом" + "uk": "Сума повинна бути цілим числом", + "ca": "L'import ha de ser un número enter" }, "GIT$BITBUCKET_TOKEN_HELP_LINK": { "en": "Bitbucket token help link", @@ -12077,7 +12831,8 @@ "fr": "Lien d'aide pour le jeton Bitbucket", "tr": "Bitbucket token yardım bağlantısı", "de": "Bitbucket-Token-Hilfe-Link", - "uk": "Посилання на довідку токена Bitbucket" + "uk": "Посилання на довідку токена Bitbucket", + "ca": "Enllaç d'ajuda per al token de Bitbucket" }, "GIT$BITBUCKET_TOKEN_SEE_MORE_LINK": { "en": "Bitbucket token see more link", @@ -12093,7 +12848,8 @@ "fr": "Lien pour en voir plus sur le jeton Bitbucket", "tr": "Bitbucket token daha fazla görme bağlantısı", "de": "Bitbucket-Token mehr sehen Link", - "uk": "Посилання для перегляду більше про токен Bitbucket" + "uk": "Посилання для перегляду більше про токен Bitbucket", + "ca": "Enlacça per veure més sobre el token de Bitbucket" }, "GIT$BITBUCKET_DC_TOKEN_HELP_LINK": { "en": "Bitbucket Data Center HTTP access token docs", @@ -12109,7 +12865,8 @@ "pt": "Documentação do token de acesso HTTP do Bitbucket Data Center", "es": "Documentación del token de acceso HTTP de Bitbucket Data Center", "tr": "Bitbucket Data Center HTTP erişim jetonu belgeleri", - "uk": "Документація HTTP-токена доступу Bitbucket Data Center" + "uk": "Документація HTTP-токена доступу Bitbucket Data Center", + "ca": "Documentació del token d'accés HTTP de Bitbucket Data Center" }, "GIT$GITHUB_TOKEN_HELP_LINK": { "en": "GitHub token help link", @@ -12125,7 +12882,8 @@ "fr": "Lien d'aide pour le jeton GitHub", "tr": "GitHub token yardım bağlantısı", "de": "GitHub-Token-Hilfe-Link", - "uk": "Посилання на довідку токена GitHub" + "uk": "Посилання на довідку токена GitHub", + "ca": "Enllaç d'ajuda per al token de GitHub" }, "GIT$GITHUB_TOKEN_SEE_MORE_LINK": { "en": "GitHub token see more link", @@ -12141,7 +12899,8 @@ "fr": "Lien pour en voir plus sur le jeton GitHub", "tr": "GitHub token daha fazla görme bağlantısı", "de": "GitHub-Token mehr sehen Link", - "uk": "Посилання для перегляду більше про токен GitHub" + "uk": "Посилання для перегляду більше про токен GitHub", + "ca": "Enlacça per veure més sobre el token de GitHub" }, "GIT$GITLAB_TOKEN_HELP_LINK": { "en": "Gitlab token help link", @@ -12157,7 +12916,8 @@ "fr": "Lien d'aide pour le jeton GitLab", "tr": "GitLab token yardım bağlantısı", "de": "GitLab-Token-Hilfe-Link", - "uk": "Посилання на довідку токена GitLab" + "uk": "Посилання на довідку токена GitLab", + "ca": "Enllaç d'ajuda per al token de GitLab" }, "GIT$GITLAB_TOKEN_SEE_MORE_LINK": { "en": "GitLab token see more link", @@ -12173,7 +12933,8 @@ "fr": "Lien pour en voir plus sur le jeton GitLab", "tr": "GitLab token daha fazla görme bağlantısı", "de": "GitLab-Token mehr sehen Link", - "uk": "Посилання для перегляду більше про токен GitLab" + "uk": "Посилання для перегляду більше про токен GitLab", + "ca": "Enlacça per veure més sobre el token de GitLab" }, "SECRETS$SECRET_ALREADY_EXISTS": { "en": "Secret already exists", @@ -12189,7 +12950,8 @@ "fr": "Le secret existe déjà", "tr": "Gizli anahtar zaten mevcut", "de": "Geheimnis existiert bereits", - "uk": "Секрет вже існує" + "uk": "Секрет вже існує", + "ca": "El secret ja existeix" }, "SECRETS$API_KEY_EXAMPLE": { "en": "e.g. OpenAI_API_Key", @@ -12205,7 +12967,8 @@ "fr": "ex. OpenAI_API_Key", "tr": "örn. OpenAI_API_Key", "de": "z.B. OpenAI_API_Key", - "uk": "наприклад OpenAI_API_Key" + "uk": "наприклад OpenAI_API_Key", + "ca": "p. ex. OpenAI_API_Key" }, "MODEL$CUSTOM_MODEL": { "en": "Custom Model", @@ -12221,7 +12984,8 @@ "fr": "Modèle personnalisé", "tr": "Özel model", "de": "Benutzerdefiniertes Modell", - "uk": "Користувацька модель" + "uk": "Користувацька модель", + "ca": "Model personalitzat" }, "SECURITY$SELECT_RISK_SEVERITY": { "en": "Select risk severity", @@ -12237,7 +13001,8 @@ "fr": "Sélectionner la gravité du risque", "tr": "Risk ciddiyetini seç", "de": "Risikoschweregrad auswählen", - "uk": "Вибрати ступінь ризику" + "uk": "Вибрати ступінь ризику", + "ca": "Selecciona la gravetat del risc" }, "SECURITY$DONT_ASK_CONFIRMATION": { "en": "Don't ask for confirmation", @@ -12253,7 +13018,8 @@ "fr": "Ne pas demander de confirmation", "tr": "Onay isteme", "de": "Nicht nach Bestätigung fragen", - "uk": "Не запитувати підтвердження" + "uk": "Не запитувати підтвердження", + "ca": "No demanis confirmació" }, "SETTINGS$MAXIMUM_BUDGET_USD": { "en": "Maximum budget per conversation in USD", @@ -12269,7 +13035,8 @@ "fr": "Budget maximum par conversation en USD", "tr": "Konuşma başına maksimum bütçe (USD)", "de": "Maximales Budget pro Gespräch in USD", - "uk": "Максимальний бюджет на розмову в доларах США" + "uk": "Максимальний бюджет на розмову в доларах США", + "ca": "Pressupost màxim per conversa en USD" }, "GIT$DISCONNECT_TOKENS": { "en": "Disconnect Tokens", @@ -12285,7 +13052,8 @@ "fr": "Déconnecter les jetons", "tr": "Token bağlantısını kes", "de": "Token trennen", - "uk": "Відключити токени" + "uk": "Відключити токени", + "ca": "Desconnecta els tokens" }, "API$TAVILY_KEY_EXAMPLE": { "en": "tvly-dev-...", @@ -12301,7 +13069,8 @@ "fr": "tvly-dev-...", "tr": "tvly-dev-...", "de": "tvly-dev-...", - "uk": "tvly-dev-..." + "uk": "tvly-dev-...", + "ca": "tvly-dev-..." }, "API$TVLY_KEY_EXAMPLE": { "en": "tvly-...", @@ -12317,7 +13086,8 @@ "fr": "tvly-...", "tr": "tvly-...", "de": "tvly-...", - "uk": "tvly-..." + "uk": "tvly-...", + "ca": "tvly-..." }, "SECRETS$CONNECT_GIT_PROVIDER": { "en": "Connect a Git provider to manage secrets", @@ -12333,7 +13103,8 @@ "fr": "Connecter un fournisseur Git pour gérer les secrets", "tr": "Gizli anahtarları yönetmek için bir Git sağlayıcısına bağlan", "de": "Git-Anbieter verbinden, um Geheimnisse zu verwalten", - "uk": "Підключити провайдера Git для управління секретами" + "uk": "Підключити провайдера Git для управління секретами", + "ca": "Connecteu un proveïdor de Git per gestionar els secrets" }, "SETTINGS$OPENHANDS_API_KEYS": { "en": "OpenHands API Keys", @@ -12349,7 +13120,8 @@ "fr": "Clés API OpenHands", "tr": "OpenHands API Anahtarları", "de": "OpenHands API-Schlüssel", - "uk": "API-ключі OpenHands" + "uk": "API-ключі OpenHands", + "ca": "Claus d'API d'OpenHands" }, "CONVERSATION$BUDGET_USAGE_FORMAT": { "en": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})", @@ -12365,7 +13137,8 @@ "fr": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})", "tr": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})", "de": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})", - "uk": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})" + "uk": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})", + "ca": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})" }, "CONVERSATION$CACHE_HIT": { "en": "Cache Hit", @@ -12381,7 +13154,8 @@ "fr": "Cache Hit", "tr": "Önbellek İsabeti", "de": "Cache-Treffer", - "uk": "Кеш-хіт" + "uk": "Кеш-хіт", + "ca": "Encert de la memòria cau" }, "CONVERSATION$CACHE_WRITE": { "en": "Cache Write", @@ -12397,7 +13171,8 @@ "fr": "Écriture Cache", "tr": "Önbellek Yazma", "de": "Cache-Schreiben", - "uk": "Запис у кеш" + "uk": "Запис у кеш", + "ca": "Escriptura a la memòria cau" }, "BUTTON$CONFIRM": { "en": "Confirm", @@ -12413,7 +13188,8 @@ "fr": "Confirmer", "tr": "Onayla", "de": "Bestätigen", - "uk": "Підтвердити" + "uk": "Підтвердити", + "ca": "Confirma" }, "FORM$VALUE": { "en": "Value", @@ -12429,7 +13205,8 @@ "fr": "Valeur", "tr": "Değer", "de": "Wert", - "uk": "Значення" + "uk": "Значення", + "ca": "Valor" }, "FORM$DESCRIPTION": { "en": "Description", @@ -12445,7 +13222,8 @@ "fr": "Description", "tr": "Açıklama", "de": "Beschreibung", - "uk": "Опис" + "uk": "Опис", + "ca": "Descripció" }, "COMMON$OPTIONAL": { "en": "Optional", @@ -12461,7 +13239,8 @@ "fr": "Optionnel", "tr": "İsteğe Bağlı", "de": "Optional", - "uk": "Необов'язково" + "uk": "Необов'язково", + "ca": "Opcional" }, "BROWSER$SERVER_MESSAGE": { "en": "No web app running. Ask OpenHands to start your project's dev server (for example: npm run dev) to see your web application here.", @@ -12477,7 +13256,8 @@ "fr": "Aucune application web n'est en cours d'exécution. Demandez à OpenHands de démarrer le serveur de développement de votre projet (par exemple : npm run dev) pour voir votre application web ici.", "tr": "Çalışan web uygulaması yok. Web uygulamanızı burada görmek için OpenHands'ten projenizin geliştirme sunucusunu başlatmasını isteyin (örneğin: npm run dev).", "de": "Keine Web-App läuft. Bitten Sie OpenHands, den Entwicklungsserver Ihres Projekts zu starten (zum Beispiel: npm run dev), um Ihre Web-Anwendung hier anzuzeigen.", - "uk": "Веб-додаток не працює. Попросіть OpenHands запустити сервер розробки вашого проєкту (наприклад: npm run dev), щоб побачити ваш веб-додаток тут." + "uk": "Веб-додаток не працює. Попросіть OpenHands запустити сервер розробки вашого проєкту (наприклад: npm run dev), щоб побачити ваш веб-додаток тут.", + "ca": "No hi ha cap aplicació web en execució. Demaneu a OpenHands que iniciï el servidor de desenvolupament del vostre projecte (per exemple: npm run dev) per veure la vostra aplicació web aquí." }, "API$NO_KEY_AVAILABLE": { "en": "No API key available", @@ -12493,7 +13273,8 @@ "fr": "Aucune clé API disponible", "tr": "Kullanılabilir API anahtarı yok", "de": "Kein API-Schlüssel verfügbar", - "uk": "Немає доступного API-ключа" + "uk": "Немає доступного API-ключа", + "ca": "No hi ha cap clau d'API disponible" }, "MICROAGENT_MANAGEMENT$TITLE": { "en": "Microagent Management", @@ -12509,7 +13290,8 @@ "fr": "Gestion des microagents", "tr": "Mikroajan Yönetimi", "de": "Microagent-Verwaltung", - "uk": "Управління мікроагентами" + "uk": "Управління мікроагентами", + "ca": "Gestió de Microagents" }, "MICROAGENT_MANAGEMENT$DESCRIPTION": { "en": "Manage Microagents", @@ -12525,7 +13307,8 @@ "fr": "Gérer les microagents", "tr": "Mikro ajanları yönet", "de": "Microagents verwalten", - "uk": "Керування мікроагентами" + "uk": "Керування мікроагентами", + "ca": "Gestiona els Microagents" }, "MICROAGENT_MANAGEMENT$USE_MICROAGENTS": { "en": "Use microagents to customize the behavior of OpenHands, teach it about your repositories, and help it work faster.", @@ -12541,7 +13324,8 @@ "fr": "Utilisez des microagents pour personnaliser le comportement d'OpenHands, lui apprendre vos dépôts et l'aider à travailler plus rapidement.", "tr": "OpenHands'in davranışını özelleştirmek, depolarınızı ona öğretmek ve daha hızlı çalışmasına yardımcı olmak için mikro ajanları kullanın.", "de": "Verwenden Sie Microagents, um das Verhalten von OpenHands anzupassen, ihm Ihre Repositories beizubringen und ihm zu helfen, schneller zu arbeiten.", - "uk": "Використовуйте мікроагенти, щоб налаштувати поведінку OpenHands, навчити його про ваші репозиторії та допомогти йому працювати швидше." + "uk": "Використовуйте мікроагенти, щоб налаштувати поведінку OpenHands, навчити його про ваші репозиторії та допомогти йому працювати швидше.", + "ca": "Feu servir els microagents per personalitzar el comportament d'OpenHands, ensenyar-li sobre els vostres repositoris i ajudar-lo a treballar més ràpid." }, "AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR": { "en": "By signing up, you agree to our", @@ -12557,7 +13341,8 @@ "fr": "En vous inscrivant, vous acceptez nos", "tr": "Kaydolarak, hizmet şartlarımızı kabul etmiş olursunuz", "de": "Mit der Anmeldung stimmen Sie unseren", - "uk": "Реєструючись, ви погоджуєтеся з нашими" + "uk": "Реєструючись, ви погоджуєтеся з нашими", + "ca": "En registrar-vos, accepteu les nostres" }, "AUTH$NO_PROVIDERS_CONFIGURED": { "en": "At least one identity provider must be configured (e.g., GitHub)", @@ -12573,7 +13358,8 @@ "fr": "Au moins un fournisseur d'identité doit être configuré (ex: GitHub)", "tr": "En az bir kimlik sağlayıcı yapılandırılmalıdır (örn. GitHub)", "de": "Mindestens ein Identitätsanbieter muss konfiguriert werden (z.B. GitHub)", - "uk": "Принаймні один постачальник ідентифікації має бути налаштований (наприклад, GitHub)" + "uk": "Принаймні один постачальник ідентифікації має бути налаштований (наприклад, GitHub)", + "ca": "S'ha de configurar almenys un proveïdor d'identitat (p. ex., GitHub)" }, "AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY": { "en": "Please check your email to verify your account.", @@ -12589,7 +13375,8 @@ "fr": "Veuillez vérifier votre e-mail pour vérifier votre compte.", "tr": "Hesabınızı doğrulamak için lütfen e-postanızı kontrol edin.", "de": "Bitte überprüfen Sie Ihre E-Mail, um Ihr Konto zu verifizieren.", - "uk": "Будь ласка, перевірте свою електронну пошту, щоб підтвердити свій обліковий запис." + "uk": "Будь ласка, перевірте свою електронну пошту, щоб підтвердити свій обліковий запис.", + "ca": "Comproveu el vostre correu electrònic per verificar el vostre compte." }, "AUTH$CHECK_INBOX_FOR_VERIFICATION_EMAIL": { "en": "Please check your inbox for the verification email we sent earlier.", @@ -12605,7 +13392,8 @@ "fr": "Veuillez vérifier votre boîte de réception pour l'e-mail de vérification que nous vous avons envoyé précédemment.", "tr": "Lütfen daha önce gönderdiğimiz doğrulama e-postası için gelen kutunuzu kontrol edin.", "de": "Bitte überprüfen Sie Ihren Posteingang auf die Bestätigungs-E-Mail, die wir Ihnen zuvor gesendet haben.", - "uk": "Будь ласка, перевірте вашу поштову скриньку на наявність листа підтвердження, який ми надіслали раніше." + "uk": "Будь ласка, перевірте вашу поштову скриньку на наявність листа підтвердження, який ми надіслали раніше.", + "ca": "Comproveu la vostra safata d'entrada per al correu electrònic de verificació que hem enviat anteriorment." }, "AUTH$EMAIL_VERIFIED_PLEASE_LOGIN": { "en": "Your email has been verified. Please login below.", @@ -12621,7 +13409,8 @@ "fr": "Votre e-mail a été vérifié. Veuillez vous connecter ci-dessous.", "tr": "E-postanız doğrulandı. Lütfen aşağıdan giriş yapın.", "de": "Ihre E-Mail wurde verifiziert. Bitte melden Sie sich unten an.", - "uk": "Вашу електронну пошту підтверджено. Будь ласка, увійдіть нижче." + "uk": "Вашу електронну пошту підтверджено. Будь ласка, увійдіть нижче.", + "ca": "El vostre correu electrònic s'ha verificat. Inicieu sessió a continuació." }, "AUTH$DUPLICATE_EMAIL_ERROR": { "en": "An account with that email address already exists.", @@ -12637,7 +13426,8 @@ "fr": "Un compte avec cette adresse e-mail existe déjà.", "tr": "Bu e-posta adresine sahip bir hesap zaten mevcut.", "de": "Für diese E-Mail-Adresse existiert bereits ein Konto.", - "uk": "Обліковий запис з цією електронною адресою вже існує." + "uk": "Обліковий запис з цією електронною адресою вже існує.", + "ca": "Ja existeix un compte amb aquesta adreça de correu electrònic." }, "AUTH$RECAPTCHA_BLOCKED": { "en": "Access blocked due to suspicious activity. If you believe this is an error, please contact contact@openhands.dev for assistance.", @@ -12653,7 +13443,8 @@ "fr": "Accès bloqué en raison d'une activité suspecte. Si vous pensez qu'il s'agit d'une erreur, veuillez contacter contact@openhands.dev pour obtenir de l'aide.", "tr": "Şüpheli aktivite nedeniyle erişim engellendi. Bunun bir hata olduğunu düşünüyorsanız, yardım için lütfen contact@openhands.dev ile iletişime geçin.", "de": "Zugriff aufgrund verdächtiger Aktivitäten blockiert. Wenn Sie glauben, dass dies ein Fehler ist, wenden Sie sich bitte an contact@openhands.dev.", - "uk": "Доступ заблоковано через підозрілу активність. Якщо ви вважаєте, що це помилка, зверніться до contact@openhands.dev за допомогою." + "uk": "Доступ заблоковано через підозрілу активність. Якщо ви вважаєте, що це помилка, зверніться до contact@openhands.dev за допомогою.", + "ca": "Accés bloquejat a causa d'activitat sospitosa. Si creieu que és un error, contacteu amb contact@openhands.dev per obtenir ajuda." }, "AUTH$LETS_GET_STARTED": { "en": "Let's get started", @@ -12669,7 +13460,8 @@ "fr": "Commençons", "tr": "Başlayalım", "de": "Lass uns anfangen", - "uk": "Почнімо" + "uk": "Почнімо", + "ca": "Comencem" }, "AUTH$INVITATION_PENDING": { "en": "Sign in to accept your organization invitation", @@ -12685,7 +13477,8 @@ "fr": "Connectez-vous pour accepter l'invitation de votre organisation", "tr": "Organizasyon davetinizi kabul etmek için giriş yapın", "de": "Melden Sie sich an, um Ihre Organisationseinladung anzunehmen", - "uk": "Увійдіть, щоб прийняти запрошення до організації" + "uk": "Увійдіть, щоб прийняти запрошення до організації", + "ca": "Inicieu sessió per acceptar la invitació de la vostra organització" }, "AUTH$BITBUCKET_SIGNUP_DISABLED": { "en": "OpenHands Cloud has temporarily disabled Bitbucket registrations and is only accepting logins from existing users at this time. We recommend registering with GitHub or GitLab instead. We are sorry for the inconvenience.", @@ -12701,7 +13494,8 @@ "fr": "OpenHands Cloud a temporairement désactivé les inscriptions Bitbucket et n'accepte actuellement que les connexions des utilisateurs existants. Nous vous recommandons de vous inscrire avec GitHub ou GitLab à la place. Nous nous excusons pour la gêne occasionnée.", "tr": "OpenHands Cloud, Bitbucket kayıtlarını geçici olarak devre dışı bıraktı ve şu anda yalnızca mevcut kullanıcıların girişlerini kabul ediyor. Bunun yerine GitHub veya GitLab ile kayıt olmanızı öneririz. Verdiğimiz rahatsızlık için özür dileriz.", "de": "OpenHands Cloud hat Bitbucket-Registrierungen vorübergehend deaktiviert und akzeptiert derzeit nur Anmeldungen von bestehenden Benutzern. Wir empfehlen, sich stattdessen mit GitHub oder GitLab zu registrieren. Wir entschuldigen uns für die Unannehmlichkeiten.", - "uk": "OpenHands Cloud тимчасово вимкнув реєстрацію через Bitbucket і наразі приймає лише вхід існуючих користувачів. Рекомендуємо зареєструватися через GitHub або GitLab. Вибачте за незручності." + "uk": "OpenHands Cloud тимчасово вимкнув реєстрацію через Bitbucket і наразі приймає лише вхід існуючих користувачів. Рекомендуємо зареєструватися через GitHub або GitLab. Вибачте за незручності.", + "ca": "OpenHands Cloud ha desactivat temporalment els registres de Bitbucket i només accepta inicis de sessió d'usuaris existents en aquest moment. Recomanem registrar-vos amb GitHub o GitLab. Disculpeu les molèsties." }, "COMMON$TERMS_OF_SERVICE": { "en": "Terms of Service", @@ -12717,7 +13511,8 @@ "fr": "Conditions d'utilisation", "tr": "Hizmet Şartları", "de": "Nutzungsbedingungen", - "uk": "Умови надання послуг" + "uk": "Умови надання послуг", + "ca": "Condicions del servei" }, "COMMON$AND": { "en": "and", @@ -12733,7 +13528,8 @@ "fr": "et", "tr": "ve", "de": "und", - "uk": "та" + "uk": "та", + "ca": "i" }, "COMMON$PRIVACY_POLICY": { "en": "Privacy Policy", @@ -12749,7 +13545,8 @@ "fr": "Politique de confidentialité", "tr": "Gizlilik Politikası", "de": "Datenschutzrichtlinie", - "uk": "Політика конфіденційності" + "uk": "Політика конфіденційності", + "ca": "Política de privadesa" }, "COMMON$PERSONAL": { "en": "Personal", @@ -12765,7 +13562,8 @@ "fr": "Personnel", "tr": "Kişisel", "de": "Persönlich", - "uk": "Особистий" + "uk": "Особистий", + "ca": "Personal" }, "COMMON$REPOSITORIES": { "en": "Repositories", @@ -12781,7 +13579,8 @@ "fr": "Dépôts", "tr": "Depolar", "de": "Repositories", - "uk": "Репозиторії" + "uk": "Репозиторії", + "ca": "Repositoris" }, "COMMON$ORGANIZATIONS": { "en": "Organizations", @@ -12797,7 +13596,8 @@ "fr": "Organisations", "tr": "Organizasyonlar", "de": "Organisationen", - "uk": "Організації" + "uk": "Організації", + "ca": "Organitzacions" }, "COMMON$ORGANIZATION": { "en": "Organization", @@ -12813,7 +13613,8 @@ "fr": "Organisation", "tr": "Organizasyon", "de": "Organisation", - "uk": "Організація" + "uk": "Організація", + "ca": "Organització" }, "COMMON$ADD_MICROAGENT": { "en": "Add Microagent", @@ -12829,7 +13630,8 @@ "fr": "Ajouter un microagent", "tr": "Mikro ajan ekle", "de": "Microagent hinzufügen", - "uk": "Додати мікроагента" + "uk": "Додати мікроагента", + "ca": "Afegeix un Microagent" }, "COMMON$CREATED_ON": { "en": "Created on", @@ -12845,7 +13647,8 @@ "fr": "Créé", "tr": "Oluşturuldu", "de": "Erstellt", - "uk": "Створено" + "uk": "Створено", + "ca": "Creat el" }, "MICROAGENT_MANAGEMENT$LEARN_THIS_REPO": { "en": "Learn this repo", @@ -12861,7 +13664,8 @@ "fr": "Apprendre ce dépôt", "tr": "Bu depoyu öğren", "de": "Dieses Repository lernen", - "uk": "Вивчити цей репозиторій" + "uk": "Вивчити цей репозиторій", + "ca": "Aprèn d'aquest repositori" }, "MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT": { "en": "Ready to add a microagent?", @@ -12877,7 +13681,8 @@ "fr": "Prêt à ajouter un microagent ?", "tr": "Bir mikro ajan eklemeye hazır mısınız?", "de": "Bereit, einen Microagent hinzuzufügen?", - "uk": "Готові додати мікроагента?" + "uk": "Готові додати мікроагента?", + "ca": "Preparat per afegir un microagent?" }, "MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES": { "en": "OpenHands can automatically learn about your repositories and store its findings as markdown in a microagent. The microagent will help OpenHands run faster and more accurately in any future conversations.", @@ -12893,7 +13698,8 @@ "fr": "OpenHands peut automatiquement apprendre à connaître vos dépôts et stocker ses découvertes en markdown dans un microagent. Le microagent aidera OpenHands à fonctionner plus rapidement et plus précisément lors de futures conversations.", "tr": "OpenHands, depolarınızı otomatik olarak öğrenebilir ve bulgularını bir mikro ajan içinde markdown olarak saklayabilir. Mikro ajan, OpenHands'in gelecekteki konuşmalarda daha hızlı ve daha doğru çalışmasına yardımcı olur.", "de": "OpenHands kann automatisch Informationen über Ihre Repositories sammeln und die Ergebnisse als Markdown in einem Microagent speichern. Der Microagent hilft OpenHands, in zukünftigen Gesprächen schneller und genauer zu arbeiten.", - "uk": "OpenHands може автоматично вивчати ваші репозиторії та зберігати свої висновки у вигляді markdown у мікроагенті. Мікроагент допоможе OpenHands працювати швидше та точніше у майбутніх розмовах." + "uk": "OpenHands може автоматично вивчати ваші репозиторії та зберігати свої висновки у вигляді markdown у мікроагенті. Мікроагент допоможе OpenHands працювати швидше та точніше у майбутніх розмовах.", + "ca": "OpenHands pot aprendre automàticament sobre els vostres repositoris i desar els seus resultats com a markdown en un microagent. El microagent ajudarà OpenHands a funcionar més ràpidament i amb més precisió en futures converses." }, "MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO": { "en": "Add a Microagent to", @@ -12909,7 +13715,8 @@ "fr": "Ajouter un microagent à", "tr": "Bir mikro ajan ekle", "de": "Microagent hinzufügen zu", - "uk": "Додати мікроагента до" + "uk": "Додати мікроагента до", + "ca": "Afegeix un Microagent a" }, "MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT": { "en": "Add a Microagent", @@ -12925,7 +13732,8 @@ "fr": "Ajouter un microagent", "tr": "Bir mikro ajan ekle", "de": "Microagent hinzufügen", - "uk": "Додати мікроагента" + "uk": "Додати мікроагента", + "ca": "Afegeix un Microagent" }, "MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT": { "en": "Update microagent", @@ -12941,7 +13749,8 @@ "fr": "Mettre à jour le microagent", "tr": "Mikro ajanı güncelle", "de": "Microagent aktualisieren", - "uk": "Оновити мікроагента" + "uk": "Оновити мікроагента", + "ca": "Actualitza el microagent" }, "MICROAGENT_MANAGEMENT$ADD_MICROAGENT_MODAL_DESCRIPTION": { "en": "OpenHands will create a new microagent based on your instructions.", @@ -12957,7 +13766,8 @@ "fr": "OpenHands créera un nouveau microagent selon vos instructions.", "tr": "OpenHands, talimatlarınıza göre yeni bir mikro ajan oluşturacaktır.", "de": "OpenHands erstellt einen neuen Microagenten basierend auf Ihren Anweisungen.", - "uk": "OpenHands створить нового мікроагента відповідно до ваших інструкцій." + "uk": "OpenHands створить нового мікроагента відповідно до ваших інструкцій.", + "ca": "OpenHands crearà un nou microagent basat en les vostres instruccions." }, "MICROAGENT_MANAGEMENT$WHAT_TO_DO": { "en": "What would you like the Microagent to do?", @@ -12973,7 +13783,8 @@ "fr": "Que souhaitez-vous que le microagent fasse ?", "tr": "Mikro ajanın ne yapmasını istersiniz?", "de": "Was soll der Microagent tun?", - "uk": "Що в,и хочете, щоб зробив мікроагент?" + "uk": "Що в,и хочете, щоб зробив мікроагент?", + "ca": "Què voleu que faci el Microagent?" }, "MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO": { "en": "Describe what you would like the Microagent to do.", @@ -12989,7 +13800,8 @@ "fr": "Décrivez ce que vous souhaitez que le microagent fasse.", "tr": "Mikro ajanın ne yapmasını istediğinizi açıklayın.", "de": "Beschreiben Sie, was der Microagent tun soll.", - "uk": "Опишіть, що ви хочете, щоб зробив мікроагент." + "uk": "Опишіть, що ви хочете, щоб зробив мікроагент.", + "ca": "Descriviu el que voleu que faci el Microagent." }, "MICROAGENT_MANAGEMENT$ADD_TRIGGERS": { "en": "Define triggers for the Microagent", @@ -13005,7 +13817,8 @@ "fr": "Définir des déclencheurs pour le microagent", "tr": "Mikro ajan için tetikleyiciler tanımlayın", "de": "Definieren Sie Auslöser für den Microagenten", - "uk": "Визначте тригери для мікроагента" + "uk": "Визначте тригери для мікроагента", + "ca": "Definiu disparadors per al Microagent" }, "MICROAGENT_MANAGEMENT$HELP_TEXT_DESCRIBING_VALID_TRIGGERS": { "en": "Enter a keyword that OpenHands will use to trigger this microagent (Optional).", @@ -13021,7 +13834,8 @@ "fr": "Entrez un mot-clé qu'OpenHands utilisera pour déclencher ce microagent (facultatif).", "tr": "OpenHands'ın bu mikro ajanı tetiklemek için kullanacağı bir anahtar kelime girin (İsteğe bağlı).", "de": "Geben Sie ein Schlüsselwort ein, das OpenHands verwendet, um diesen Microagenten auszulösen (optional).", - "uk": "Введіть ключове слово, яке OpenHands використовуватиме для запуску цього мікроагента (необов'язково)." + "uk": "Введіть ключове слово, яке OpenHands використовуватиме для запуску цього мікроагента (необов'язково).", + "ca": "Introduïu una paraula clau que OpenHands utilitzarà per activar aquest microagent (opcional)." }, "COMMON$FOR_EXAMPLE": { "en": "For example", @@ -13037,7 +13851,8 @@ "fr": "Par exemple", "tr": "Örneğin", "de": "Zum Beispiel", - "uk": "Наприклад:" + "uk": "Наприклад:", + "ca": "Per exemple" }, "COMMON$TEST_DB_MIGRATION": { "en": "Test DB Migration", @@ -13053,7 +13868,8 @@ "fr": "Tester la migration de la base de données", "tr": "Veritabanı geçişini test et", "de": "Datenbankmigration testen", - "uk": "Тестування міграції БД" + "uk": "Тестування міграції БД", + "ca": "Prova la migració de BD" }, "COMMON$RUN_TEST": { "en": "Run Test", @@ -13069,7 +13885,8 @@ "fr": "Exécuter le test", "tr": "Testi çalıştır", "de": "Test ausführen", - "uk": "Запустити тест" + "uk": "Запустити тест", + "ca": "Executa la prova" }, "COMMON$RUN_APP": { "en": "Run App", @@ -13085,7 +13902,8 @@ "fr": "Exécuter l'application", "tr": "Uygulamayı çalıştır", "de": "App ausführen", - "uk": "Запустити додаток" + "uk": "Запустити додаток", + "ca": "Executa l'aplicació" }, "COMMON$LEARN_FILE_STRUCTURE": { "en": "Learn File Structure", @@ -13101,7 +13919,8 @@ "fr": "Apprendre la structure des fichiers", "tr": "Dosya yapısını öğren", "de": "Dateistruktur lernen", - "uk": "Вивчити структуру файлів" + "uk": "Вивчити структуру файлів", + "ca": "Aprèn l'estructura de fitxers" }, "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS": { "en": "You do not have user-level microagents", @@ -13117,7 +13936,8 @@ "fr": "Vous n'avez pas de microagents au niveau utilisateur", "tr": "Kullanıcı düzeyinde mikro ajanınız yok", "de": "Sie haben keine Mikroagenten auf Benutzerebene", - "uk": "У вас немає мікроагентів на рівні користувача" + "uk": "У вас немає мікроагентів на рівні користувача", + "ca": "No teniu microagents a nivell d'usuari" }, "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS": { "en": "You do not have microagents", @@ -13133,7 +13953,8 @@ "fr": "Vous n'avez pas de microagents", "tr": "Mikro ajanınız yok", "de": "Sie haben keine Mikroagenten", - "uk": "У вас немає мікроагентів" + "uk": "У вас немає мікроагентів", + "ca": "No teniu microagents" }, "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS": { "en": "You do not have organization-level microagents", @@ -13149,7 +13970,8 @@ "fr": "Vous n'avez pas de microagents au niveau organisation", "tr": "Organizasyon düzeyinde mikro ajanınız yok", "de": "Sie haben keine Mikroagenten auf Organisationsebene", - "uk": "У вас немає мікроагентів на рівні організації" + "uk": "У вас немає мікроагентів на рівні організації", + "ca": "No teniu microagents a nivell d'organització" }, "COMMON$SEARCH_REPOSITORIES": { "en": "Search repositories", @@ -13165,7 +13987,8 @@ "fr": "Rechercher des dépôts", "tr": "Depo ara", "de": "Repositorys durchsuchen", - "uk": "Пошук репозиторіїв" + "uk": "Пошук репозиторіїв", + "ca": "Cerca repositoris" }, "COMMON$READY_FOR_REVIEW": { "en": "Ready for review", @@ -13181,7 +14004,8 @@ "fr": "Prêt pour la relecture", "tr": "İncelemeye hazır", "de": "Bereit zur Überprüfung", - "uk": "Готово до перегляду" + "uk": "Готово до перегляду", + "ca": "Preparat per a la revisió" }, "COMMON$COMPLETED": { "en": "Completed", @@ -13197,7 +14021,8 @@ "fr": "Terminé", "tr": "Tamamlandı", "de": "Abgeschlossen", - "uk": "Завершено" + "uk": "Завершено", + "ca": "Completat" }, "COMMON$COMPLETED_PARTIALLY": { "en": "Completed partially", @@ -13213,7 +14038,8 @@ "fr": "Partiellement terminé", "tr": "Kısmen tamamlandı", "de": "Teilweise abgeschlossen", - "uk": "Частково завершено" + "uk": "Частково завершено", + "ca": "Completat parcialment" }, "COMMON$STOPPED": { "en": "Stopped", @@ -13229,7 +14055,8 @@ "fr": "Arrêté", "tr": "Durduruldu", "de": "Gestoppt", - "uk": "Зупинено" + "uk": "Зупинено", + "ca": "Aturat" }, "COMMON$WORKING_ON_IT": { "en": "Working on it", @@ -13245,7 +14072,8 @@ "fr": "En cours", "tr": "Üzerinde çalışılıyor", "de": "Wird bearbeitet", - "uk": "В процесі виконання" + "uk": "В процесі виконання", + "ca": "Treballant-hi" }, "MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT": { "en": "We're working on it! Once OpenHands is done investigating, you'll be able to review its pull request before merging your new microagent.", @@ -13261,7 +14089,8 @@ "fr": "Nous y travaillons ! Une fois qu'OpenHands aura terminé l'investigation, vous pourrez examiner sa pull request avant de fusionner votre nouveau microagent.", "tr": "Üzerinde çalışıyoruz! OpenHands incelemeyi bitirdiğinde, yeni mikro ajanınızı birleştirmeden önce pull request'i gözden geçirebileceksiniz.", "de": "Wir arbeiten daran! Sobald OpenHands die Untersuchung abgeschlossen hat, können Sie den Pull Request überprüfen, bevor Sie Ihren neuen Microagenten zusammenführen.", - "uk": "Ми працюємо над цим! Після завершення розслідування OpenHands ви зможете переглянути його pull request перед об'єднанням нового мікроагента." + "uk": "Ми працюємо над цим! Після завершення розслідування OpenHands ви зможете переглянути його pull request перед об'єднанням нового мікроагента.", + "ca": "Hi estem treballant! Un cop OpenHands hagi acabat d'investigar, podreu revisar la seva sol·licitud de canvis abans de fusionar el vostre nou microagent." }, "MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY": { "en": "Your microagent is ready! Merge the PR in GitHub to start using it.", @@ -13277,7 +14106,8 @@ "fr": "Votre micro-agent est prêt ! Fusionnez la PR sur GitHub pour commencer à l'utiliser.", "tr": "Mikro ajanınız hazır! Kullanmak için GitHub'da PR'ı birleştirin.", "de": "Ihr Microagent ist bereit! Führen Sie den PR in GitHub zusammen, um ihn zu verwenden.", - "uk": "Ваш мікроагент готовий! Злийте PR у GitHub, щоб почати ним користуватися." + "uk": "Ваш мікроагент готовий! Злийте PR у GitHub, щоб почати ним користуватися.", + "ca": "El vostre microagent està preparat! Fusioneu la PR a GitHub per començar a fer-lo servir." }, "COMMON$REVIEW_PR_IN": { "en": "Review PR in", @@ -13293,7 +14123,8 @@ "fr": "Examiner la PR sur", "tr": "PR'ı şurada gözden geçir:", "de": "PR überprüfen in", - "uk": "Переглянути PR у" + "uk": "Переглянути PR у", + "ca": "Revisa la PR a" }, "COMMON$EDIT_IN": { "en": "Edit in", @@ -13309,7 +14140,8 @@ "fr": "Modifier dans", "tr": "Şurada düzenle:", "de": "Bearbeiten in", - "uk": "Редагувати у" + "uk": "Редагувати у", + "ca": "Edita a" }, "COMMON$LEARN": { "en": "Learn", @@ -13325,7 +14157,8 @@ "fr": "Apprendre", "tr": "Öğren", "de": "Lernen", - "uk": "Вчитися" + "uk": "Вчитися", + "ca": "Aprèn" }, "COMMON$LEARN_SOMETHING_NEW": { "en": "Learn something new", @@ -13341,7 +14174,8 @@ "fr": "Apprendre quelque chose de nouveau", "tr": "Yeni bir şey öğren", "de": "Etwas Neues lernen", - "uk": "Вивчити щось нове" + "uk": "Вивчити щось нове", + "ca": "Aprèn alguna cosa nova" }, "COMMON$STARTING": { "en": "Starting", @@ -13357,7 +14191,8 @@ "fr": "Démarrage", "tr": "Başlatılıyor", "de": "Wird gestartet", - "uk": "Запуск" + "uk": "Запуск", + "ca": "Iniciant" }, "COMMON$STOPPING": { "en": "Stopping...", @@ -13373,7 +14208,8 @@ "fr": "Arrêt...", "tr": "Durduruluyor...", "de": "Wird gestoppt...", - "uk": "Зупинка..." + "uk": "Зупинка...", + "ca": "Aturant..." }, "MICROAGENT_MANAGEMENT$ERROR": { "en": "The system has encountered an error. Please try again later.", @@ -13389,7 +14225,8 @@ "fr": "Le système a rencontré une erreur. Veuillez réessayer plus tard.", "tr": "Sistem bir hata ile karşılaştı. Lütfen daha sonra tekrar deneyin.", "de": "Das System hat einen Fehler festgestellt. Bitte versuchen Sie es später erneut.", - "uk": "Система зіткнулася з помилкою. Будь ласка, спробуйте пізніше." + "uk": "Система зіткнулася з помилкою. Будь ласка, спробуйте пізніше.", + "ca": "El sistema ha trobat un error. Torneu-ho a intentar més tard." }, "MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED": { "en": "The conversation has been stopped.", @@ -13405,7 +14242,8 @@ "fr": "La conversation a été arrêtée.", "tr": "Konuşma durduruldu.", "de": "Das Gespräch wurde gestoppt.", - "uk": "Розмову зупинено." + "uk": "Розмову зупинено.", + "ca": "La conversa s'ha aturat." }, "MICROAGENT_MANAGEMENT$LEARN_THIS_REPO_MODAL_TITLE": { "en": "Learn this repository?", @@ -13421,7 +14259,8 @@ "fr": "Apprendre ce dépôt ?", "tr": "Bu depoyu öğrenmek ister misiniz?", "de": "Dieses Repository lernen?", - "uk": "Вивчити цей репозиторій?" + "uk": "Вивчити цей репозиторій?", + "ca": "Aprendre d'aquest repositori?" }, "MICROAGENT_MANAGEMENT$LEARN_THIS_REPO_MODAL_DESCRIPTION": { "en": "Do you want OpenHands to launch a new conversation to help you understand this repository?", @@ -13437,7 +14276,8 @@ "fr": "Voulez-vous qu'OpenHands lance une nouvelle conversation pour vous aider à comprendre ce dépôt ?", "tr": "OpenHands'in bu depoyu anlamanıza yardımcı olacak yeni bir konuşma başlatmasını ister misiniz?", "de": "Möchten Sie, dass OpenHands eine neue Unterhaltung startet, um Ihnen dieses Repository zu erklären?", - "uk": "Бажаєте, щоб OpenHands розпочав нову розмову, щоб допомогти вам зрозуміти цей репозиторій?" + "uk": "Бажаєте, щоб OpenHands розпочав нову розмову, щоб допомогти вам зрозуміти цей репозиторій?", + "ca": "Voleu que OpenHands iniciï una nova conversa per ajudar-vos a entendre aquest repositori?" }, "MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO": { "en": "What would you like to know about this repository? (optional)", @@ -13453,7 +14293,8 @@ "fr": "Que souhaitez-vous savoir sur ce dépôt ? (facultatif)", "tr": "Bu depo hakkında ne bilmek istersiniz? (isteğe bağlı)", "de": "Was möchten Sie über dieses Repository wissen? (optional)", - "uk": "Що ви хотіли б дізнатися про цей репозиторій? (необов'язково)" + "uk": "Що ви хотіли б дізнатися про цей репозиторій? (необов'язково)", + "ca": "Què voleu saber sobre aquest repositori? (opcional)" }, "MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO": { "en": "Describe what you would like to know about this repository.", @@ -13469,7 +14310,8 @@ "fr": "Décrivez ce que vous souhaitez savoir sur ce dépôt.", "tr": "Bu depo hakkında ne bilmek istediğinizi açıklayın.", "de": "Beschreiben Sie, was Sie über dieses Repository wissen möchten.", - "uk": "Опишіть, що ви хотіли б дізнатися про цей репозиторій." + "uk": "Опишіть, що ви хотіли б дізнатися про цей репозиторій.", + "ca": "Descriviu el que voleu saber sobre aquest repositori." }, "MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT_MODAL_DESCRIPTION": { "en": "OpenHands will update the microagent based on your instructions.", @@ -13485,7 +14327,8 @@ "fr": "OpenHands mettra à jour le microagent selon vos instructions.", "tr": "OpenHands, talimatlarınıza göre mikro ajanı güncelleyecektir.", "de": "OpenHands aktualisiert den Microagenten basierend auf Ihren Anweisungen.", - "uk": "OpenHands оновить мікроагента відповідно до ваших інструкцій." + "uk": "OpenHands оновить мікроагента відповідно до ваших інструкцій.", + "ca": "OpenHands actualitzarà el microagent basant-se en les vostres instruccions." }, "SETTINGS$GIT_USERNAME": { "en": "Git Username", @@ -13501,7 +14344,8 @@ "fr": "Nom d'utilisateur Git", "tr": "Git Kullanıcı Adı", "de": "Git-Benutzername", - "uk": "Ім'я користувача Git" + "uk": "Ім'я користувача Git", + "ca": "Nom d'usuari de Git" }, "SETTINGS$GIT_EMAIL": { "en": "Git Email", @@ -13517,7 +14361,8 @@ "fr": "Email Git", "tr": "Git E-posta", "de": "Git-E-Mail", - "uk": "Електронна пошта Git" + "uk": "Електронна пошта Git", + "ca": "Correu electrònic de Git" }, "PROJECT_MANAGEMENT$TITLE": { "en": "Project Management", @@ -13533,7 +14378,8 @@ "fr": "Gestion de projet", "tr": "Proje Yönetimi", "de": "Projektmanagement", - "uk": "Управління проектами" + "uk": "Управління проектами", + "ca": "Gestió de projectes" }, "PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR": { "en": "Failed to validate integration. Please try again later.", @@ -13549,7 +14395,8 @@ "fr": "Échec de la validation de l'intégration. Veuillez réessayer ultérieurement.", "tr": "Entegrasyon doğrulanamadı. Lütfen daha sonra tekrar deneyin.", "de": "Die Integration konnte nicht validiert werden. Bitte versuchen Sie es später noch einmal.", - "uk": "Не вдалося перевірити інтеграцію. Спробуйте пізніше." + "uk": "Не вдалося перевірити інтеграцію. Спробуйте пізніше.", + "ca": "No s'ha pogut validar la integració. Torneu-ho a intentar més tard." }, "PROJECT_MANAGEMENT$LINK_BUTTON_LABEL": { "en": "Link", @@ -13565,7 +14412,8 @@ "fr": "Lien", "tr": "Bağlantı", "de": "Link", - "uk": "Посилання" + "uk": "Посилання", + "ca": "Vincula" }, "PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL": { "en": "Unlink", @@ -13581,7 +14429,8 @@ "fr": "Dissocier", "tr": "Bağlantıyı kaldır", "de": "Verknüpfung aufheben", - "uk": "Від’єднати" + "uk": "Від’єднати", + "ca": "Desvincula" }, "PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE": { "en": "Link Workspace", @@ -13597,7 +14446,8 @@ "fr": "Lier l'espace de travail", "tr": "Çalışma Alanını Bağla", "de": "Arbeitsbereich verknüpfen", - "uk": "Пов'язати робочу область" + "uk": "Пов'язати робочу область", + "ca": "Vincula l'espai de treball" }, "PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL": { "en": "Configure", @@ -13613,7 +14463,8 @@ "fr": "Configurer", "tr": "Yapılandır", "de": "Konfigurieren", - "uk": "Налаштувати" + "uk": "Налаштувати", + "ca": "Configura" }, "PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL": { "en": "Edit", @@ -13629,7 +14480,8 @@ "fr": "Modifier", "tr": "Düzenle", "de": "Bearbeiten", - "uk": "Редагувати" + "uk": "Редагувати", + "ca": "Edita" }, "PROJECT_MANAGEMENT$UPDATE_BUTTON_LABEL": { "en": "Update", @@ -13645,7 +14497,8 @@ "fr": "Mettre à jour", "tr": "Güncelle", "de": "Aktualisieren", - "uk": "Оновити" + "uk": "Оновити", + "ca": "Actualitza" }, "PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE": { "en": "Configure {{platform}} Integration", @@ -13661,7 +14514,8 @@ "fr": "Configurer l'intégration {{platform}}", "tr": "{{platform}} Entegrasyonunu Yapılandır", "de": "{{platform}}-Integration konfigurieren", - "uk": "Налаштувати інтеграцію {{platform}}" + "uk": "Налаштувати інтеграцію {{platform}}", + "ca": "Configura la integració de {{platform}}" }, "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_1": { "en": "Important: Make sure the workspace integration for your target workspace is already configured. Check the documentation for more information.", @@ -13677,7 +14531,8 @@ "fr": "Important :Assurez-vous que l'intégration de l'espace de travail cible est déjà configurée. Consultez la documentation pour plus d'informations.", "tr": "Önemli: Hedef çalışma alanınız için çalışma alanı entegrasyonunun zaten yapılandırılmış olduğundan emin olun. Daha fazla bilgi için belgelere bakın.", "de": "Wichtig:Stellen Sie sicher, dass die Arbeitsbereichsintegration für Ihren Zielarbeitsbereich bereits konfiguriert ist. Weitere Informationen finden Sie in der Dokumentation.", - "uk": "Важливо: Переконайтеся, що інтеграцію робочого простору для вашого цільового робочого простору вже налаштовано. Перегляньте документацію для отримання додаткової інформації." + "uk": "Важливо: Переконайтеся, що інтеграцію робочого простору для вашого цільового робочого простору вже налаштовано. Перегляньте документацію для отримання додаткової інформації.", + "ca": "Important: Assegureu-vos que la integració de l'espai de treball per al vostre espai de treball de destinació ja estigui configurada. Consulteu la documentació per obtenir més informació." }, "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION_STAGE_2": { "en": "Important: Check the documentation for more information about configuring the workspace integration or updating an existing integration.", @@ -13693,7 +14548,8 @@ "fr": "Important : Consultez la documentation pour plus d'informations sur la configuration de l'intégration de l'espace de travail ou la mise à jour d'une intégration existante.", "tr": "Önemli: Çalışma alanı entegrasyonunu yapılandırma veya mevcut bir entegrasyonu güncelleme hakkında daha fazla bilgi için belgelere bakın.", "de": "Wichtig: Weitere Informationen zur Konfiguration der Arbeitsbereichsintegration oder zur Aktualisierung einer bestehenden Integration finden Sie in der Dokumentation.", - "uk": "Важливо: Перегляньте документацію для отримання додаткової інформації про налаштування інтеграції робочого простору або оновлення існуючої інтеграції." + "uk": "Важливо: Перегляньте документацію для отримання додаткової інформації про налаштування інтеграції робочого простору або оновлення існуючої інтеграції.", + "ca": "Important: Consulteu la documentació per obtenir més informació sobre la configuració de la integració de l'espai de treball o l'actualització d'una integració existent." }, "PROJECT_MANAGEMENT$WORKSPACE_NAME_HINT": { "en": "Workspace name can be found in the browser URL when you're accessing a resource (eg: issue) in {{platform}}.", @@ -13709,7 +14565,8 @@ "fr": "Le nom de l'espace de travail peut être trouvé dans l'URL du navigateur lorsque vous accédez à une ressource (ex : issue) dans {{platform}}.", "tr": "Çalışma alanı adı, {{platform}}'da bir kaynağa (örn: sorun) erişirken tarayıcı URL'sinde bulunabilir.", "de": "Der Arbeitsbereichsname ist in der Browser-URL zu finden, wenn Sie auf eine Ressource (z.B.: Issue) in {{platform}} zugreifen.", - "uk": "Назву робочого простору можна знайти в URL браузера під час доступу до ресурсу (наприклад: проблема) в {{platform}}." + "uk": "Назву робочого простору можна знайти в URL браузера під час доступу до ресурсу (наприклад: проблема) в {{platform}}.", + "ca": "El nom de l'espai de treball es pot trobar a la URL del navegador quan accediu a un recurs (p. ex.: problema) a {{platform}}." }, "PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL": { "en": "Workspace Name", @@ -13725,7 +14582,8 @@ "fr": "Nom de l'espace de travail", "tr": "Çalışma Alanı Adı", "de": "Arbeitsbereichsname", - "uk": "Назва робочої області" + "uk": "Назва робочої області", + "ca": "Nom de l'espai de treball" }, "PROJECT_MANAGEMENT$JIRA_WORKSPACE_NAME_PLACEHOLDER": { "en": "yourcompany.atlassian.net", @@ -13741,7 +14599,8 @@ "fr": "yourcompany.atlassian.net", "tr": "yourcompany.atlassian.net", "de": "yourcompany.atlassian.net", - "uk": "yourcompany.atlassian.net" + "uk": "yourcompany.atlassian.net", + "ca": "yourcompany.atlassian.net" }, "PROJECT_MANAGEMENT$JIRA_DC_WORKSPACE_NAME_PLACEHOLDER": { "en": "jira.yourcompany.com", @@ -13757,7 +14616,8 @@ "fr": "jira.yourcompany.com", "tr": "jira.yourcompany.com", "de": "jira.yourcompany.com", - "uk": "jira.yourcompany.com" + "uk": "jira.yourcompany.com", + "ca": "jira.yourcompany.com" }, "PROJECT_MANAGEMENT$LINEAR_WORKSPACE_NAME_PLACEHOLDER": { "en": "yourcompany", @@ -13773,7 +14633,8 @@ "fr": "yourcompany", "tr": "yourcompany", "de": "yourcompany", - "uk": "yourcompany" + "uk": "yourcompany", + "ca": "yourcompany" }, "PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL": { "en": "Webhook Secret", @@ -13789,7 +14650,8 @@ "fr": "Secret du webhook", "tr": "Web Kancası Sırrı", "de": "Webhook-Geheimnis", - "uk": "Секрет вебхуку" + "uk": "Секрет вебхуку", + "ca": "Secret del webhook" }, "PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER": { "en": "whsec_abcd1234efgh5678ijkl", @@ -13805,7 +14667,8 @@ "fr": "whsec_abcd1234efgh5678ijkl", "tr": "whsec_abcd1234efgh5678ijkl", "de": "whsec_abcd1234efgh5678ijkl", - "uk": "whsec_abcd1234efgh5678ijkl" + "uk": "whsec_abcd1234efgh5678ijkl", + "ca": "whsec_abcd1234efgh5678ijkl" }, "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL": { "en": "Service Account Email", @@ -13821,7 +14684,8 @@ "fr": "E-mail du compte de service", "tr": "Hizmet Hesabı E-postası", "de": "E-Mail-Adresse des Dienstkontos", - "uk": "Електронна адреса облікового запису служби" + "uk": "Електронна адреса облікового запису служби", + "ca": "Correu electrònic del compte de servei" }, "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_PLACEHOLDER": { "en": "test@example.com", @@ -13837,7 +14701,8 @@ "fr": "test@example.com", "tr": "test@example.com", "de": "test@example.com", - "uk": "test@example.com" + "uk": "test@example.com", + "ca": "test@example.com" }, "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_LABEL": { "en": "Service Account API Key", @@ -13853,7 +14718,8 @@ "fr": "Clé API du compte de service", "tr": "Hizmet Hesabı API Anahtarı", "de": "API-Schlüssel des Dienstkontos", - "uk": "Ключ API облікового запису служби" + "uk": "Ключ API облікового запису служби", + "ca": "Clau d'API del compte de servei" }, "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER": { "en": "sk-1234567890abcdef...", @@ -13869,7 +14735,8 @@ "fr": "sk-1234567890abcdef...", "tr": "sk-1234567890abcdef...", "de": "sk-1234567890abcdef...", - "uk": "sk-1234567890abcdef..." + "uk": "sk-1234567890abcdef...", + "ca": "sk-1234567890abcdef..." }, "PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL": { "en": "Connect", @@ -13885,7 +14752,8 @@ "fr": "Connecter", "tr": "Bağlamak", "de": "Verbinden", - "uk": "Підключитися" + "uk": "Підключитися", + "ca": "Connecta" }, "PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL": { "en": "Active", @@ -13901,7 +14769,8 @@ "fr": "Actif", "tr": "Aktif", "de": "Aktiv", - "uk": "Активний" + "uk": "Активний", + "ca": "Actiu" }, "PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR": { "en": "Workspace name can only contain letters, numbers, hyphens, and underscores", @@ -13917,7 +14786,8 @@ "fr": "Le nom de l'espace de travail ne peut contenir que des lettres, des chiffres, des tirets et des traits de soulignement", "tr": "Çalışma alanı adı yalnızca harfler, sayılar, tire ve alt çizgi içerebilir", "de": "Der Arbeitsbereichsname darf nur Buchstaben, Zahlen, Bindestriche und Unterstriche enthalten", - "uk": "Назва робочого простору може містити тільки літери, цифри, дефіси та підкреслення" + "uk": "Назва робочого простору може містити тільки літери, цифри, дефіси та підкреслення", + "ca": "El nom de l'espai de treball només pot contenir lletres, números, guions i guions baixos" }, "PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR": { "en": "Webhook secret cannot contain spaces", @@ -13933,7 +14803,8 @@ "fr": "Le secret du webhook ne peut pas contenir d'espaces", "tr": "Webhook sırrı boşluk içeremez", "de": "Das Webhook-Geheimnis darf keine Leerzeichen enthalten", - "uk": "Секрет веб-хука не може містити пробіли" + "uk": "Секрет веб-хука не може містити пробіли", + "ca": "El secret del webhook no pot contenir espais" }, "PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR": { "en": "Please enter a valid email address", @@ -13949,7 +14820,8 @@ "fr": "Veuillez saisir une adresse e-mail valide", "tr": "Lütfen geçerli bir e-posta adresi girin", "de": "Bitte geben Sie eine gültige E-Mail-Adresse ein", - "uk": "Будь ласка, введіть дійсну адресу електронної пошти" + "uk": "Будь ласка, введіть дійсну адресу електронної пошти", + "ca": "Introduïu una adreça de correu electrònic vàlida" }, "PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR": { "en": "API key cannot contain spaces", @@ -13965,7 +14837,8 @@ "fr": "La clé API ne peut pas contenir d'espaces", "tr": "API anahtarı boşluk içeremez", "de": "Der API-Schlüssel darf keine Leerzeichen enthalten", - "uk": "Ключ API не може містити пробіли" + "uk": "Ключ API не може містити пробіли", + "ca": "La clau d'API no pot contenir espais" }, "MICROAGENT_MANAGEMENT$ERROR_LOADING_MICROAGENT_CONTENT": { "en": "Error loading microagent content.", @@ -13981,7 +14854,8 @@ "fr": "Erreur lors du chargement du contenu du microagent.", "tr": "Mikro ajan içeriği yüklenirken hata oluştu.", "de": "Fehler beim Laden des Microagent-Inhalts.", - "uk": "Помилка під час завантаження вмісту мікроагента." + "uk": "Помилка під час завантаження вмісту мікроагента.", + "ca": "Error en carregar el contingut del microagent." }, "SETTINGS$MCP_SERVER_TYPE_SSE": { "en": "SSE", @@ -13997,7 +14871,8 @@ "fr": "SSE", "tr": "SSE", "de": "SSE", - "uk": "SSE" + "uk": "SSE", + "ca": "SSE" }, "SETTINGS$MCP_SERVER_TYPE_STDIO": { "en": "STDIO", @@ -14013,7 +14888,8 @@ "fr": "STDIO", "tr": "STDIO", "de": "STDIO", - "uk": "STDIO" + "uk": "STDIO", + "ca": "STDIO" }, "SETTINGS$MCP_SERVER_TYPE_SHTTP": { "en": "SHTTP", @@ -14029,7 +14905,8 @@ "fr": "SHTTP", "tr": "SHTTP", "de": "SHTTP", - "uk": "SHTTP" + "uk": "SHTTP", + "ca": "SHTTP" }, "SETTINGS$MCP_ERROR_URL_REQUIRED": { "en": "URL is required", @@ -14045,7 +14922,8 @@ "fr": "L'URL est requise", "tr": "URL gereklidir", "de": "URL ist erforderlich", - "uk": "URL обов'язковий" + "uk": "URL обов'язковий", + "ca": "La URL és obligatòria" }, "SETTINGS$MCP_ERROR_URL_INVALID_PROTOCOL": { "en": "URL must use http:// or https://", @@ -14061,7 +14939,8 @@ "fr": "L'URL doit utiliser http:// ou https://", "tr": "URL http:// veya https:// kullanmalıdır", "de": "URL muss http:// oder https:// verwenden", - "uk": "URL повинен використовувати http:// або https://" + "uk": "URL повинен використовувати http:// або https://", + "ca": "La URL ha de fer servir http:// o https://" }, "SETTINGS$MCP_ERROR_URL_INVALID": { "en": "Invalid URL format", @@ -14077,7 +14956,8 @@ "fr": "Format d'URL invalide", "tr": "Geçersiz URL formatı", "de": "Ungültiges URL-Format", - "uk": "Недійсний формат URL" + "uk": "Недійсний формат URL", + "ca": "Format de URL no vàlid" }, "SETTINGS$MCP_ERROR_NAME_REQUIRED": { "en": "Name is required", @@ -14093,7 +14973,8 @@ "fr": "Le nom est requis", "tr": "Ad gereklidir", "de": "Name ist erforderlich", - "uk": "Ім'я обов'язкове" + "uk": "Ім'я обов'язкове", + "ca": "El nom és obligatori" }, "SETTINGS$MCP_ERROR_NAME_INVALID": { "en": "Name can only contain letters, numbers, hyphens, and underscores", @@ -14109,7 +14990,8 @@ "fr": "Le nom ne peut contenir que des lettres, des chiffres, des tirets et des traits de soulignement", "tr": "Ad yalnızca harf, rakam, tire ve alt çizgi içerebilir", "de": "Name darf nur Buchstaben, Zahlen, Bindestriche und Unterstriche enthalten", - "uk": "Ім'я може містити лише літери, цифри, дефіси та підкреслення" + "uk": "Ім'я може містити лише літери, цифри, дефіси та підкреслення", + "ca": "El nom només pot contenir lletres, números, guions i guions baixos" }, "SETTINGS$MCP_ERROR_NAME_DUPLICATE": { "en": "A STDIO server with this name already exists", @@ -14125,7 +15007,8 @@ "fr": "Un serveur STDIO avec ce nom existe déjà", "tr": "Bu adda bir STDIO sunucusu zaten mevcut", "de": "Ein STDIO-Server mit diesem Namen existiert bereits", - "uk": "STDIO сервер з цим ім'ям вже існує" + "uk": "STDIO сервер з цим ім'ям вже існує", + "ca": "Ja existeix un servidor STDIO amb aquest nom" }, "SETTINGS$MCP_ERROR_COMMAND_REQUIRED": { "en": "Command is required", @@ -14141,7 +15024,8 @@ "fr": "La commande est requise", "tr": "Komut gereklidir", "de": "Befehl ist erforderlich", - "uk": "Команда обов'язкова" + "uk": "Команда обов'язкова", + "ca": "La comanda és obligatòria" }, "SETTINGS$MCP_ERROR_COMMAND_NO_SPACES": { "en": "Command cannot contain spaces", @@ -14157,7 +15041,8 @@ "fr": "La commande ne peut pas contenir d'espaces", "tr": "Komut boşluk içeremez", "de": "Befehl darf keine Leerzeichen enthalten", - "uk": "Команда не може містити пробіли" + "uk": "Команда не може містити пробіли", + "ca": "La comanda no pot contenir espais" }, "SETTINGS$MCP_ERROR_URL_DUPLICATE": { "en": "A server with this URL already exists for the selected type", @@ -14173,7 +15058,8 @@ "fr": "A server with this URL already exists for the selected type", "tr": "A server with this URL already exists for the selected type", "de": "A server with this URL already exists for the selected type", - "uk": "A server with this URL already exists for the selected type" + "uk": "A server with this URL already exists for the selected type", + "ca": "Ja existeix un servidor amb aquesta URL per al tipus seleccionat" }, "SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT": { "en": "Environment variables must follow KEY=value format", @@ -14189,7 +15075,8 @@ "fr": "Environment variables must follow KEY=value format", "tr": "Environment variables must follow KEY=value format", "de": "Environment variables must follow KEY=value format", - "uk": "Environment variables must follow KEY=value format" + "uk": "Environment variables must follow KEY=value format", + "ca": "Les variables d'entorn han de seguir el format CLAU=valor" }, "SETTINGS$MCP_ERROR_TIMEOUT_INVALID_NUMBER": { "en": "Timeout must be a valid number", @@ -14205,7 +15092,8 @@ "fr": "Le timeout doit être un nombre valide", "tr": "Zaman aşımı geçerli bir sayı olmalıdır", "de": "Timeout muss eine gültige Zahl sein", - "uk": "Таймаут повинен бути дійсним числом" + "uk": "Таймаут повинен бути дійсним числом", + "ca": "El temps d'espera ha de ser un número vàlid" }, "SETTINGS$MCP_ERROR_TIMEOUT_POSITIVE": { "en": "Timeout must be positive", @@ -14221,7 +15109,8 @@ "fr": "Le timeout doit être positif", "tr": "Zaman aşımı pozitif olmalıdır", "de": "Timeout muss positiv sein", - "uk": "Таймаут повинен бути позитивним" + "uk": "Таймаут повинен бути позитивним", + "ca": "El temps d'espera ha de ser positiu" }, "SETTINGS$MCP_ERROR_TIMEOUT_MAX_EXCEEDED": { "en": "Timeout cannot exceed 3600 seconds (1 hour)", @@ -14237,7 +15126,8 @@ "fr": "Le timeout ne peut pas dépasser 3600 secondes (1 heure)", "tr": "Zaman aşımı 3600 saniyeyi (1 saat) aşamaz", "de": "Timeout kann 3600 Sekunden (1 Stunde) nicht überschreiten", - "uk": "Таймаут не може перевищувати 3600 секунд (1 година)" + "uk": "Таймаут не може перевищувати 3600 секунд (1 година)", + "ca": "El temps d'espera no pot superar els 3600 segons (1 hora)" }, "SETTINGS$MCP_SERVER_TYPE": { "en": "Server Type", @@ -14253,7 +15143,8 @@ "fr": "Type de serveur", "tr": "Sunucu türü", "de": "Server-Typ", - "uk": "Тип сервера" + "uk": "Тип сервера", + "ca": "Tipus de servidor" }, "SETTINGS$MCP_API_KEY_PLACEHOLDER": { "en": "Enter API key (optional)", @@ -14269,7 +15160,8 @@ "fr": "Saisir la clé API (optionnel)", "tr": "API anahtarını girin (isteğe bağlı)", "de": "API-Schlüssel eingeben (optional)", - "uk": "Введіть API ключ (необов'язково)" + "uk": "Введіть API ключ (необов'язково)", + "ca": "Introduïu la clau d'API (opcional)" }, "SETTINGS$MCP_COMMAND_ARGUMENTS": { "en": "Command Arguments", @@ -14285,7 +15177,8 @@ "fr": "Arguments de commande", "tr": "Komut argümanları", "de": "Befehlsargumente", - "uk": "Аргументи команди" + "uk": "Аргументи команди", + "ca": "Arguments de la comanda" }, "SETTINGS$MCP_COMMAND_ARGUMENTS_HELP": { "en": "Enter each argument on a separate line. Arguments will be passed to the command in the order listed.", @@ -14301,7 +15194,8 @@ "fr": "Entrez chaque argument sur une ligne séparée. Les arguments seront passés à la commande dans l'ordre listé.", "tr": "Her argümanı ayrı bir satıra girin. Argümanlar listedeki sırayla komuta aktarılacaktır.", "de": "Geben Sie jedes Argument in einer separaten Zeile ein. Die Argumente werden in der aufgelisteten Reihenfolge an den Befehl übergeben.", - "uk": "Введіть кожен аргument в окремому рядку. Аргументи будуть передані команді в порядку, в якому вони перераховані." + "uk": "Введіть кожен аргument в окремому рядку. Аргументи будуть передані команді в порядку, в якому вони перераховані.", + "ca": "Introduïu cada argument en una línia separada. Els arguments es passaran a la comanda en l'ordre indicat." }, "SETTINGS$MCP_ENVIRONMENT_VARIABLES": { "en": "Environment Variables", @@ -14317,7 +15211,8 @@ "fr": "Variables d'environnement", "tr": "Ortam değişkenleri", "de": "Umgebungsvariablen", - "uk": "Змінні середовища" + "uk": "Змінні середовища", + "ca": "Variables d'entorn" }, "SETTINGS$MCP_ADD_SERVER": { "en": "Add Server", @@ -14333,7 +15228,8 @@ "fr": "Ajouter un serveur", "tr": "Sunucu ekle", "de": "Server hinzufügen", - "uk": "Додати сервер" + "uk": "Додати сервер", + "ca": "Afegeix un servidor" }, "SETTINGS$MCP_SAVE_SERVER": { "en": "Save Server", @@ -14349,7 +15245,8 @@ "fr": "Enregistrer le serveur", "tr": "Sunucuyu kaydet", "de": "Server speichern", - "uk": "Зберегти сервер" + "uk": "Зберегти сервер", + "ca": "Desa el servidor" }, "SETTINGS$MCP_NO_SERVERS": { "en": "No servers configured", @@ -14365,7 +15262,8 @@ "fr": "Aucun serveur configuré", "tr": "Yapılandırılmış sunucu yok", "de": "Keine Server konfiguriert", - "uk": "Сервери не налаштовані" + "uk": "Сервери не налаштовані", + "ca": "No hi ha servidors configurats" }, "SETTINGS$MCP_SERVER_DETAILS": { "en": "Server Details", @@ -14381,7 +15279,8 @@ "fr": "Détails du serveur", "tr": "Sunucu detayları", "de": "Server-Details", - "uk": "Деталі сервера" + "uk": "Деталі сервера", + "ca": "Detalls del servidor" }, "SETTINGS$MCP_CONFIRM_DELETE": { "en": "Are you sure you want to delete this server?", @@ -14397,7 +15296,8 @@ "fr": "Êtes-vous sûr de vouloir supprimer ce serveur ?", "tr": "Bu sunucuyu silmek istediğinizden emin misiniz?", "de": "Sind Sie sicher, dass Sie diesen Server löschen möchten?", - "uk": "Ви впевнені, що хочете видалити цей сервер?" + "uk": "Ви впевнені, що хочете видалити цей сервер?", + "ca": "Esteu segur que voleu eliminar aquest servidor?" }, "SETTINGS$MCP_CONFIRM_CHANGES": { "en": "Confirm Changes", @@ -14413,7 +15313,8 @@ "fr": "Confirmer les modifications", "tr": "Değişiklikleri Onayla", "de": "Änderungen bestätigen", - "uk": "Підтвердити зміни" + "uk": "Підтвердити зміни", + "ca": "Confirma els canvis" }, "SETTINGS$MCP_DEFAULT_CONFIG": { "en": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}", @@ -14429,7 +15330,8 @@ "fr": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}", "tr": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}", "de": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}", - "uk": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}" + "uk": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}", + "ca": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}" }, "PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER": { "en": "myworkspace", @@ -14445,7 +15347,8 @@ "fr": "monworkspace", "tr": "benimworkspace", "de": "meinarbeitsbereich", - "uk": "моя-робоча-область" + "uk": "моя-робоча-область", + "ca": "myworkspace" }, "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION": { "en": "Important: Check the documentation for more information about configuring the workspace integration or updating an existing integration.", @@ -14461,7 +15364,8 @@ "fr": "Important : Consultez la documentation pour plus d'informations sur la configuration de l'intégration de l'espace de travail ou la mise à jour d'une intégration existante.", "tr": "Önemli: Çalışma alanı entegrasyonunu yapılandırma veya mevcut bir entegrasyonu güncelleme hakkında daha fazla bilgi için belgelere bakın.", "de": "Wichtig: Weitere Informationen zur Konfiguration der Arbeitsbereichsintegration oder zur Aktualisierung einer bestehenden Integration finden Sie in der Dokumentation.", - "uk": "Важливо: Перегляньте документацію для отримання додаткової інформації про налаштування інтеграції робочого простору або оновлення існуючої інтеграції." + "uk": "Важливо: Перегляньте документацію для отримання додаткової інформації про налаштування інтеграції робочого простору або оновлення існуючої інтеграції.", + "ca": "Important: Consulteu la documentació per obtenir més informació sobre la configuració de la integració de l'espai de treball o l'actualització d'una integració existent." }, "PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION": { "en": "Important: Make sure the workspace integration for your target workspace is already configured. Check the documentation for more information.", @@ -14477,7 +15381,8 @@ "fr": "Important :Assurez-vous que l'intégration de l'espace de travail cible est déjà configurée. Consultez la documentation pour plus d'informations.", "tr": "Önemli: Hedef çalışma alanınız için çalışma alanı entegrasyonunun zaten yapılandırılmış olduğundan emin olun. Daha fazla bilgi için belgelere bakın.", "de": "Wichtig:Stellen Sie sicher, dass die Arbeitsbereichsintegration für Ihren Zielarbeitsbereich bereits konfiguriert ist. Weitere Informationen finden Sie in der Dokumentation.", - "uk": "Важливо: Переконайтеся, що інтеграцію робочого простору для вашого цільового робочого простору вже налаштовано. Перегляньте документацію для отримання додаткової інформації." + "uk": "Важливо: Переконайтеся, що інтеграцію робочого простору для вашого цільового робочого простору вже налаштовано. Перегляньте документацію для отримання додаткової інформації.", + "ca": "Important: Assegureu-vos que la integració de l'espai de treball per al vostre espai de treball de destinació ja estigui configurada. Consulteu la documentació per obtenir més informació." }, "SETTINGS": { "en": "Environment variables must follow KEY=value format", @@ -14493,7 +15398,8 @@ "fr": "A server with this URL already exists for the selected type", "tr": "A server with this URL already exists for the selected type", "de": "A server with this URL already exists for the selected type", - "uk": "A server with this URL already exists for the selected type" + "uk": "A server with this URL already exists for the selected type", + "ca": "Les variables d'entorn han de seguir el format CLAU=valor" }, "MICROAGENT_MANAGEMENT$OPENING_PR_TO_CREATE_MICROAGENT": { "en": "Opening a PR to create the microagent for you...", @@ -14509,7 +15415,8 @@ "fr": "Ouverture d'une PR pour créer le microagent pour vous...", "tr": "Sizin için mikro ajanı oluşturmak üzere bir PR açılıyor...", "de": "Es wird ein PR geöffnet, um den Microagent für Sie zu erstellen...", - "uk": "Відкривається PR для створення мікроагента для вас..." + "uk": "Відкривається PR для створення мікроагента для вас...", + "ca": "Obrint una PR per crear el microagent per a vosaltres..." }, "MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW": { "en": "PR is ready for review! The microagent has been created successfully.", @@ -14525,7 +15432,8 @@ "fr": "La PR est prête pour révision ! Le microagent a été créé avec succès.", "tr": "PR incelemeye hazır! Mikro ajan başarıyla oluşturuldu.", "de": "PR ist bereit zur Überprüfung! Der Microagent wurde erfolgreich erstellt.", - "uk": "PR готовий до перегляду! Мікроагента успішно створено." + "uk": "PR готовий до перегляду! Мікроагента успішно створено.", + "ca": "La PR està preparada per a la revisió! El microagent s'ha creat correctament." }, "MICROAGENT_MANAGEMENT$PR_NOT_CREATED": { "en": "The agent has finished its task but was unable to create a PR.", @@ -14541,7 +15449,8 @@ "fr": "L'agent a terminé sa tâche mais n'a pas pu créer de PR.", "tr": "Ajan görevini tamamladı ancak bir PR oluşturamadı.", "de": "Der Agent hat seine Aufgabe abgeschlossen, konnte aber keinen PR erstellen.", - "uk": "Агент завершив завдання, але не зміг створити PR." + "uk": "Агент завершив завдання, але не зміг створити PR.", + "ca": "L'agent ha acabat la seva tasca però no ha pogut crear una PR." }, "MICROAGENT_MANAGEMENT$ERROR_CREATING_MICROAGENT": { "en": "Something went wrong. Try initiating the microagent again.", @@ -14557,7 +15466,8 @@ "fr": "Une erreur s'est produite. Essayez de relancer le microagent.", "tr": "Bir şeyler ters gitti. Mikro ajanı tekrar başlatmayı deneyin.", "de": "Etwas ist schiefgelaufen. Versuchen Sie, den Microagenten erneut zu starten.", - "uk": "Щось пішло не так. Спробуйте ініціювати мікроагента ще раз." + "uk": "Щось пішло не так. Спробуйте ініціювати мікроагента ще раз.", + "ca": "Alguna cosa ha anat malament. Intenteu iniciar el microagent de nou." }, "MICROAGENT$STATUS_WAITING": { "en": "Waiting for runtime to start...", @@ -14573,7 +15483,8 @@ "fr": "En attente du démarrage du runtime...", "tr": "Çalışma zamanının başlaması bekleniyor...", "de": "Warten auf den Start der Laufzeit...", - "uk": "Очікування запуску середовища виконання..." + "uk": "Очікування запуску середовища виконання...", + "ca": "Esperant que l'entorn d'execució s'iniciï..." }, "MICROAGENT$UNKNOWN_ERROR": { "en": "Unknown error, please try again", @@ -14589,7 +15500,8 @@ "fr": "Erreur inconnue, veuillez réessayer", "tr": "Bilinmeyen hata, lütfen tekrar deneyin", "de": "Unbekannter Fehler, bitte versuchen Sie es erneut", - "uk": "Невідома помилка, спробуйте ще раз" + "uk": "Невідома помилка, спробуйте ще раз", + "ca": "Error desconegut, torneu-ho a intentar" }, "MICROAGENT$CONVERSATION_STARTING": { "en": "Starting conversation...", @@ -14605,7 +15517,8 @@ "fr": "Démarrage de la conversation...", "tr": "Konuşma başlatılıyor...", "de": "Gespräch wird gestartet...", - "uk": "Розпочинається розмова..." + "uk": "Розпочинається розмова...", + "ca": "Iniciant la conversa..." }, "MICROAGENT_MANAGEMENT$EXISTING_MICROAGENTS": { "en": "Existing Microagents", @@ -14621,7 +15534,8 @@ "fr": "Microagents existants", "tr": "Mevcut Mikroajanlar", "de": "Vorhandene Mikroagenten", - "uk": "Існуючі мікроагенти" + "uk": "Існуючі мікроагенти", + "ca": "Microagents existents" }, "SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT": { "en": "LLM Analyzer (Default)", @@ -14637,7 +15551,8 @@ "fr": "Analyseur LLM (Par défaut)", "tr": "LLM Analizörü (Varsayılan)", "de": "LLM-Analysator (Standard)", - "uk": "Аналізатор LLM (За замовчуванням)" + "uk": "Аналізатор LLM (За замовчуванням)", + "ca": "Analitzador LLM (per defecte)" }, "SETTINGS$SECURITY_ANALYZER_NONE": { "en": "None (Ask for every command)", @@ -14653,7 +15568,8 @@ "fr": "Aucun (Demander pour chaque commande)", "tr": "Yok (Her komutta sor)", "de": "Keine (Bei jedem Befehl nachfragen)", - "uk": "Немає (Запитувати для кожної команди)" + "uk": "Немає (Запитувати для кожної команди)", + "ca": "Cap (demana per cada comanda)" }, "SETTINGS$SECURITY_ANALYZER_INVARIANT": { "en": "Invariant Rule-based Analyzer", @@ -14669,7 +15585,8 @@ "fr": "Analyseur à base de règles Invariant", "tr": "Invariant Kural Tabanlı Analizör", "de": "Invariant regelbasierter Analysator", - "uk": "Аналізатор на основі правил Invariant" + "uk": "Аналізатор на основі правил Invariant", + "ca": "Analitzador basat en regles Invariant" }, "COMMON$HIGH_RISK": { "en": "High Risk", @@ -14685,7 +15602,8 @@ "fr": "Risque élevé", "tr": "Yüksek Risk", "de": "Hohes Risiko", - "uk": "Високий ризик" + "uk": "Високий ризик", + "ca": "Alt risc" }, "COMMON$SEE": { "en": "see", @@ -14701,7 +15619,8 @@ "fr": "voir", "tr": "bak", "de": "siehe", - "uk": "переглянути" + "uk": "переглянути", + "ca": "consulteu" }, "COMMON$ADVANCED_SETTINGS": { "en": "advanced settings", @@ -14717,7 +15636,8 @@ "fr": "paramètres avancés", "tr": "gelişmiş ayarlar", "de": "erweiterte Einstellungen", - "uk": "розширені налаштування" + "uk": "розширені налаштування", + "ca": "configuració avançada" }, "SECURITY_ANALYZER$UNKNOWN_RISK": { "en": "Unknown Risk", @@ -14733,7 +15653,8 @@ "fr": "Risque inconnu", "tr": "Bilinmeyen risk", "ja": "不明なリスク", - "uk": "Невідомий ризик" + "uk": "Невідомий ризик", + "ca": "Risc desconegut" }, "SECURITY_ANALYZER$LOW_RISK": { "en": "Low Risk", @@ -14749,7 +15670,8 @@ "fr": "Risque faible", "tr": "Düşük risk", "ja": "低リスク", - "uk": "Низький ризик" + "uk": "Низький ризик", + "ca": "Risc baix" }, "SECURITY_ANALYZER$MEDIUM_RISK": { "en": "Medium Risk", @@ -14765,7 +15687,8 @@ "fr": "Risque moyen", "tr": "Orta risk", "ja": "中リスク", - "uk": "Середній ризик" + "uk": "Середній ризик", + "ca": "Risc mitjà" }, "SECURITY_ANALYZER$HIGH_RISK": { "en": "High Risk", @@ -14781,7 +15704,8 @@ "fr": "Risque élevé", "tr": "Yüksek risk", "ja": "高リスク", - "uk": "Високий ризик" + "uk": "Високий ризик", + "ca": "Risc alt" }, "ACTION$CONFIRM_DELETE": { "en": "Confirm Delete", @@ -14797,7 +15721,8 @@ "ar": "تأكيد الحذف", "no": "Bekreft sletting", "tr": "Silme Onayı", - "uk": "Підтвердити видалення" + "uk": "Підтвердити видалення", + "ca": "Confirma l'eliminació" }, "ACTION$CONFIRM_STOP": { "en": "Confirm Stop", @@ -14813,7 +15738,8 @@ "ar": "تأكيد الإيقاف", "no": "Bekreft stopp", "tr": "Durdurmayı Onayla", - "uk": "Підтвердити зупинку" + "uk": "Підтвердити зупинку", + "ca": "Confirma l'aturada" }, "ACTION$CONFIRM_CLOSE": { "en": "Confirm Close", @@ -14829,7 +15755,8 @@ "ar": "تأكيد الإغلاق", "no": "Bekreft lukking", "tr": "Kapatmayı Onayla", - "uk": "Підтвердити закриття" + "uk": "Підтвердити закриття", + "ca": "Confirma el tancament" }, "ACTION$CONFIRM_UPDATE": { "en": "Confirm Update", @@ -14845,7 +15772,8 @@ "ar": "تأكيد التحديث", "no": "Bekreft oppdatering", "tr": "Güncellemeyi Onayla", - "uk": "Підтвердити оновлення" + "uk": "Підтвердити оновлення", + "ca": "Confirma l'actualització" }, "AGENT_STATUS$AGENT_STOPPED": { "en": "Agent stopped", @@ -14861,7 +15789,8 @@ "fr": "Agent arrêté.", "tr": "Ajan durdu.", "de": "Agent gestoppt.", - "uk": "Агент зупинився." + "uk": "Агент зупинився.", + "ca": "Agent aturat" }, "AGENT_STATUS$ERROR_OCCURRED": { "en": "Error occurred", @@ -14877,7 +15806,8 @@ "fr": "Une erreur s'est produite.", "tr": "Hata oluştu.", "de": "Ein Fehler ist aufgetreten.", - "uk": "Сталася помилка." + "uk": "Сталася помилка.", + "ca": "S'ha produït un error" }, "AGENT_STATUS$INITIALIZING": { "en": "Initializing agent...", @@ -14893,7 +15823,8 @@ "fr": "Initialisation de l'agent.", "tr": "Ajan başlatılıyor.", "de": "Agent wird initialisiert.", - "uk": "Ініціалізація агента." + "uk": "Ініціалізація агента.", + "ca": "Inicialitzant l'agent..." }, "AGENT_STATUS$RUNNING_TASK": { "en": "Running task", @@ -14909,7 +15840,8 @@ "fr": "Exécution de la tâche.", "tr": "Görev yürütülüyor.", "de": "Aufgabe wird ausgeführt.", - "uk": "Виконує завдання." + "uk": "Виконує завдання.", + "ca": "Executant la tasca" }, "AGENT_STATUS$WAITING_FOR_TASK": { "en": "Waiting for task", @@ -14925,7 +15857,8 @@ "fr": "En attente de tâche.", "tr": "Görev bekleniyor.", "de": "Warten auf Aufgabe.", - "uk": "Очікує завдання." + "uk": "Очікує завдання.", + "ca": "Esperant una tasca" }, "BUTTON$DELETE_CONVERSATION": { "en": "Delete Conversation", @@ -14941,7 +15874,8 @@ "it": "Elimina conversazione", "pt": "Excluir conversa", "es": "Eliminar conversación", - "tr": "Sohbeti sil" + "tr": "Sohbeti sil", + "ca": "Elimina la conversa" }, "BUTTON$RENAME": { "en": "Rename", @@ -14957,7 +15891,8 @@ "fr": "Renommer", "tr": "Yeniden adlandır", "de": "Umbenennen", - "uk": "Перейменувати" + "uk": "Перейменувати", + "ca": "Canvia el nom" }, "COMMON$APP": { "en": "App", @@ -14973,7 +15908,8 @@ "fr": "Application", "tr": "Uygulama", "de": "App", - "uk": "Додаток" + "uk": "Додаток", + "ca": "Aplicació" }, "COMMON$PLANNER": { "en": "Planner", @@ -14989,7 +15925,8 @@ "fr": "Planificateur", "tr": "Planlayıcı", "de": "Planer", - "uk": "Планувальник" + "uk": "Планувальник", + "ca": "Planificador" }, "COMMON$APPLICATION_SETTINGS": { "en": "Application Settings", @@ -15005,7 +15942,8 @@ "fr": "Paramètres de l'application", "tr": "Uygulama Ayarları", "de": "Anwendungseinstellungen", - "uk": "Налаштування застосунку" + "uk": "Налаштування застосунку", + "ca": "Configuració de l'aplicació" }, "COMMON$BROWSER": { "en": "Browser", @@ -15021,7 +15959,8 @@ "fr": "Navigateur", "tr": "Tarayıcı", "de": "Browser", - "uk": "Браузер" + "uk": "Браузер", + "ca": "Navegador" }, "COMMON$CHANGES": { "en": "Changes", @@ -15037,7 +15976,8 @@ "fr": "Modifications", "tr": "Değişiklikler", "de": "Änderungen", - "uk": "Зміни" + "uk": "Зміни", + "ca": "Canvis" }, "COMMON$CLICK_HERE": { "en": "Click here", @@ -15053,7 +15993,8 @@ "fr": "Cliquez ici", "tr": "Buraya tıklayın", "de": "Hier klicken", - "uk": "Натисніть тут" + "uk": "Натисніть тут", + "ca": "Feu clic aquí" }, "COMMON$CLOSE_CONVERSATION_STOP_RUNTIME": { "en": "Close Conversation (Stop Sandbox)", @@ -15069,7 +16010,8 @@ "fr": "Fermer la conversation (Arrêter le sandbox)", "tr": "Konuşmayı Kapat (Sandbox'ı Durdur)", "de": "Gespräch schließen (Sandbox beenden)", - "uk": "Закрити розмову (зупинити пісочницю)" + "uk": "Закрити розмову (зупинити пісочницю)", + "ca": "Tanca la conversa (atura l'entorn d'execució)" }, "COMMON$CODE": { "en": "Code", @@ -15085,7 +16027,8 @@ "fr": "Code", "tr": "Kod", "de": "Code", - "uk": "Код" + "uk": "Код", + "ca": "Codi" }, "COMMON$CREATE_NEW_BRANCH": { "en": "Create New Branch", @@ -15101,7 +16044,8 @@ "fr": "Créer une nouvelle branche", "tr": "Yeni Dal Oluştur", "de": "Neuen Branch erstellen", - "uk": "Створити нову гілку" + "uk": "Створити нову гілку", + "ca": "Crea una nova branca" }, "COMMON$CREATE_PR": { "en": "Create PR", @@ -15117,7 +16061,8 @@ "fr": "Créer une PR", "tr": "PR Oluştur", "de": "PR erstellen", - "uk": "Створити PR" + "uk": "Створити PR", + "ca": "Crea una PR" }, "COMMON$DELETE_CONVERSATION": { "en": "Delete Conversation", @@ -15133,7 +16078,8 @@ "fr": "Supprimer la conversation", "tr": "Konuşmayı Sil", "de": "Gespräch löschen", - "uk": "Видалити розмову" + "uk": "Видалити розмову", + "ca": "Elimina la conversa" }, "COMMON$DROP_YOUR_FILES_HERE": { "en": "Drop your files here", @@ -15149,7 +16095,8 @@ "fr": "Déposez vos fichiers ici", "tr": "Dosyalarınızı buraya bırakın", "de": "Legen Sie Ihre Dateien hier ab", - "uk": "Перетягніть ваші файли сюди" + "uk": "Перетягніть ваші файли сюди", + "ca": "Deixeu els fitxers aquí" }, "COMMON$ERROR": { "en": "Error", @@ -15165,7 +16112,8 @@ "fr": "Erreur", "tr": "Hata", "de": "Fehler", - "uk": "Помилка" + "uk": "Помилка", + "ca": "Error" }, "COMMON$GIT_PULL": { "en": "Git Pull", @@ -15181,7 +16129,8 @@ "fr": "Git Pull", "tr": "Git Pull", "de": "Git Pull", - "uk": "Git Pull" + "uk": "Git Pull", + "ca": "Git Pull" }, "COMMON$GIT_PUSH": { "en": "Git Push", @@ -15197,7 +16146,8 @@ "fr": "Git Push", "tr": "Git Push", "de": "Git Push", - "uk": "Git Push" + "uk": "Git Push", + "ca": "Git Push" }, "COMMON$GIT_TOOLS": { "en": "Git Tools", @@ -15213,7 +16163,8 @@ "fr": "Outils Git", "tr": "Git Araçları", "de": "Git-Tools", - "uk": "Інструменти Git" + "uk": "Інструменти Git", + "ca": "Eines de Git" }, "COMMON$GIT_TOOLS_DISABLED_CONTENT": { "en": "Git tools are only available for connected repositories", @@ -15229,7 +16180,8 @@ "ar": "أدوات Git متاحة فقط للمستودعات المتصلة", "no": "Git-verktøy er kun tilgjengelig for tilkoblede depoter", "tr": "Git araçları yalnızca bağlı depolar için kullanılabilir", - "uk": "Інструменти Git доступні лише для підключених репозиторіїв" + "uk": "Інструменти Git доступні лише для підключених репозиторіїв", + "ca": "Les eines de Git només estan disponibles per als repositoris connectats" }, "COMMON$JUPYTER": { "en": "Jupyter", @@ -15245,7 +16197,8 @@ "fr": "Jupyter", "tr": "Jupyter", "de": "Jupyter", - "uk": "Jupyter" + "uk": "Jupyter", + "ca": "Jupyter" }, "COMMON$LANGUAGE_MODEL_LLM": { "en": "Language Model (LLM)", @@ -15261,7 +16214,8 @@ "fr": "Modèle de langue (LLM)", "tr": "Dil Modeli (LLM)", "de": "Sprachmodell (LLM)", - "uk": "Мовна модель (LLM)" + "uk": "Мовна модель (LLM)", + "ca": "Model de llenguatge (LLM)" }, "COMMON$MACROS": { "en": "Macros", @@ -15277,7 +16231,8 @@ "fr": "Macros", "tr": "Makrolar", "de": "Makros", - "uk": "Макроси" + "uk": "Макроси", + "ca": "Macros" }, "COMMON$MODEL_CONTEXT_PROTOCOL_MCP": { "en": "Model Context Protocol (MCP)", @@ -15293,7 +16248,8 @@ "fr": "Protocole de contexte de modèle (MCP)", "tr": "Model Bağlam Protokolü (MCP)", "de": "Modellkontextprotokoll (MCP)", - "uk": "Протокол контексту моделі (MCP)" + "uk": "Протокол контексту моделі (MCP)", + "ca": "Protocol de Context de Model (MCP)" }, "COMMON$NEW_CONVERSATION": { "en": "New Conversation", @@ -15309,7 +16265,8 @@ "fr": "Nouvelle conversation", "tr": "Yeni Konuşma", "de": "Neues Gespräch", - "uk": "Нова розмова" + "uk": "Нова розмова", + "ca": "Nova conversa" }, "COMMON$NO_BRANCH": { "en": "No Branch", @@ -15325,7 +16282,8 @@ "ar": "لا يوجد فرع", "no": "Ingen gren", "tr": "Dal Yok", - "uk": "Гілка відсутня" + "uk": "Гілка відсутня", + "ca": "Sense branca" }, "COMMON$NO_REPOSITORY": { "en": "No Repository", @@ -15341,7 +16299,8 @@ "fr": "Aucun dépôt", "tr": "Depo yok", "de": "Kein Repository", - "uk": "Немає репозиторію" + "uk": "Немає репозиторію", + "ca": "Sense repositori" }, "COMMON$NO_REPO_CONNECTED": { "en": "No Repo Connected", @@ -15357,7 +16316,8 @@ "ar": "لا يوجد مستودع متصل", "no": "Ingen repo tilkoblet", "tr": "Bağlı Depo Yok", - "uk": "Репозиторій не підключено" + "uk": "Репозиторій не підключено", + "ca": "Sense repositori connectat" }, "COMMON$OPEN_REPOSITORY": { "en": "Open Repository", @@ -15373,7 +16333,8 @@ "fr": "Ouvrir le dépôt", "tr": "Depoyu Aç", "de": "Repository öffnen", - "uk": "Відкрити репозиторій" + "uk": "Відкрити репозиторій", + "ca": "Obre el repositori" }, "COMMON$PULL": { "en": "Pull", @@ -15389,7 +16350,8 @@ "ar": "سحب", "no": "Pull", "tr": "Pull", - "uk": "Пул" + "uk": "Пул", + "ca": "Pull" }, "COMMON$PULL_REQUEST": { "en": "Pull Request", @@ -15405,7 +16367,8 @@ "ar": "طلب السحب", "no": "Trekkforespørsel", "tr": "Çekme İsteği", - "uk": "Запит на злиття" + "uk": "Запит на злиття", + "ca": "Sol·licitud de canvis" }, "COMMON$PUSH": { "en": "Push", @@ -15421,7 +16384,8 @@ "ar": "دفع", "no": "Push", "tr": "Push", - "uk": "Пуш" + "uk": "Пуш", + "ca": "Push" }, "COMMON$RECENT_CONVERSATIONS": { "en": "Recent Conversations", @@ -15437,7 +16401,8 @@ "fr": "Conversations récentes", "tr": "Son Konuşmalar", "de": "Kürzliche Gespräche", - "uk": "Останні розмови" + "uk": "Останні розмови", + "ca": "Converses recents" }, "COMMON$RECENT_PROJECTS": { "en": "Recent Projects", @@ -15453,7 +16418,8 @@ "fr": "Projets récents", "tr": "Son Projeler", "de": "Kürzliche Projekte", - "uk": "Останні проєкти" + "uk": "Останні проєкти", + "ca": "Projectes recents" }, "COMMON$RUN": { "en": "Run", @@ -15469,7 +16435,8 @@ "ar": "تشغيل", "no": "Kjør", "tr": "Çalıştır", - "uk": "Запустити" + "uk": "Запустити", + "ca": "Executa" }, "COMMON$RUNNING": { "en": "Running", @@ -15485,7 +16452,8 @@ "fr": "En cours d'exécution", "tr": "Çalışıyor", "de": "Läuft", - "uk": "Працює" + "uk": "Працює", + "ca": "En execució" }, "COMMON$WAITING_FOR_SANDBOX": { "en": "Waiting for sandbox", @@ -15501,7 +16469,8 @@ "fr": "En attente du bac à sable", "tr": "Sandbox bekleniyor", "de": "Warten auf Sandbox", - "uk": "Очікування пісочниці" + "uk": "Очікування пісочниці", + "ca": "Esperant el sandbox" }, "COMMON$SELECT_GIT_PROVIDER": { "en": "Select Git provider", @@ -15517,7 +16486,8 @@ "fr": "Sélectionnez le fournisseur Git", "tr": "Git sağlayıcısı seç", "de": "Git-Anbieter auswählen", - "uk": "Виберіть постачальника Git" + "uk": "Виберіть постачальника Git", + "ca": "Selecciona el proveïdor de Git" }, "COMMON$SERVER_STATUS": { "en": "Server Status", @@ -15533,7 +16503,8 @@ "fr": "Statut du serveur", "tr": "Sunucu Durumu", "de": "Serverstatus", - "uk": "Статус сервера" + "uk": "Статус сервера", + "ca": "Estat del servidor" }, "COMMON$SERVER_STOPPED": { "en": "Server Stopped", @@ -15549,7 +16520,8 @@ "fr": "Serveur arrêté", "tr": "Sunucu durdu", "de": "Server gestoppt", - "uk": "Сервер зупинено" + "uk": "Сервер зупинено", + "ca": "Servidor aturat" }, "COMMON$START_FROM_SCRATCH": { "en": "Start from Scratch", @@ -15565,7 +16537,8 @@ "fr": "Commencer de zéro", "tr": "Sıfırdan Başla", "de": "Von vorne beginnen", - "uk": "Почати з нуля" + "uk": "Почати з нуля", + "ca": "Comença des de zero" }, "COMMON$START_SERVER": { "en": "Start Server", @@ -15581,7 +16554,8 @@ "fr": "Démarrer le serveur", "tr": "Sunucuyu başlat", "de": "Server starten", - "uk": "Запустити сервер" + "uk": "Запустити сервер", + "ca": "Inicia el servidor" }, "COMMON$START_CONVERSATION": { "en": "Start Conversation", @@ -15597,7 +16571,8 @@ "fr": "Démarrer la conversation", "tr": "Sohbeti başlat", "de": "Unterhaltung starten", - "uk": "Почати розмову" + "uk": "Почати розмову", + "ca": "Inicia la conversa" }, "COMMON$STOP_SERVER": { "en": "Stop Server", @@ -15613,7 +16588,8 @@ "fr": "Arrêter le serveur", "tr": "Sunucuyu durdur", "de": "Server stoppen", - "uk": "Зупинити сервер" + "uk": "Зупинити сервер", + "ca": "Atura el servidor" }, "COMMON$TERMINAL": { "en": "Terminal (read-only)", @@ -15629,7 +16605,8 @@ "fr": "Terminal (lecture seule)", "tr": "Terminal (salt okunur)", "de": "Terminal (schreibgeschützt)", - "uk": "Термінал (тільки читання)" + "uk": "Термінал (тільки читання)", + "ca": "Terminal (només lectura)" }, "COMMON$UNKNOWN": { "en": "Unknown", @@ -15645,7 +16622,8 @@ "fr": "Inconnu", "tr": "Bilinmiyor", "de": "Unbekannt", - "uk": "Невідомо" + "uk": "Невідомо", + "ca": "Desconegut" }, "COMMON$USER_SETTINGS": { "en": "User Settings", @@ -15661,7 +16639,8 @@ "fr": "Paramètres utilisateur", "tr": "Kullanıcı Ayarları", "de": "Benutzereinstellungen", - "uk": "Налаштування користувача" + "uk": "Налаштування користувача", + "ca": "Configuració de l'usuari" }, "COMMON$VIEW": { "en": "View", @@ -15677,7 +16656,8 @@ "ar": "عرض", "no": "Vis", "tr": "Görüntüle", - "uk": "Переглянути" + "uk": "Переглянути", + "ca": "Visualitza" }, "COMMON$VIEW_LESS": { "en": "View Less", @@ -15693,7 +16673,8 @@ "fr": "Voir moins", "tr": "Daha Az Göster", "de": "Weniger anzeigen", - "uk": "Переглянути менше" + "uk": "Переглянути менше", + "ca": "Mostra menys" }, "COMMON$VIEW_MORE": { "en": "View More", @@ -15709,7 +16690,8 @@ "fr": "Voir plus", "tr": "Daha Fazla Göster", "de": "Mehr anzeigen", - "uk": "Переглянути більше" + "uk": "Переглянути більше", + "ca": "Mostra més" }, "COMMON$ARCHIVED": { "en": "Archived", @@ -15725,7 +16707,8 @@ "fr": "Archivé", "tr": "Arşivlendi", "de": "Archiviert", - "uk": "Архівовано" + "uk": "Архівовано", + "ca": "Arxivat" }, "HOME$GUIDE_MESSAGE_TITLE": { "en": "New around here? Not sure where to start?", @@ -15741,7 +16724,8 @@ "fr": "Nouveau ici ? Vous ne savez pas par où commencer ?", "tr": "Buralarda yeni misiniz? Nereden başlayacağınızı bilmiyor musunuz?", "de": "Neu hier? Nicht sicher, wo du anfangen sollst?", - "uk": "Вперше тут? Не знаєте, з чого почати?" + "uk": "Вперше тут? Не знаєте, з чого почати?", + "ca": "Sou nou aquí? No sabeu per on començar?" }, "HOME$NEW_PROJECT_DESCRIPTION": { "en": "Start a new conversation that is not connected to an existing repository.", @@ -15757,7 +16741,8 @@ "fr": "Démarrez une nouvelle conversation qui n'est pas connectée à un dépôt existant.", "tr": "Mevcut bir depoya bağlı olmayan yeni bir konuşma başlatın.", "de": "Beginnen Sie ein neues Gespräch, das nicht mit einem bestehenden Repository verbunden ist.", - "uk": "Почніть нову розмову, яка не пов'язана з існуючим репозиторієм." + "uk": "Почніть нову розмову, яка не пов'язана з існуючим репозиторієм.", + "ca": "Inicia una nova conversa que no estigui connectada a un repositori existent." }, "HOME$NO_RECENT_CONVERSATIONS": { "en": "No recent conversations", @@ -15773,7 +16758,8 @@ "fr": "Aucune conversation récente", "tr": "Son konuşma yok", "de": "Keine aktuellen Unterhaltungen", - "uk": "Немає недавніх розмов" + "uk": "Немає недавніх розмов", + "ca": "No hi ha converses recents" }, "HOME$SELECT_OR_INSERT_URL": { "en": "Select or insert a URL", @@ -15789,7 +16775,8 @@ "ar": "اختر أو أدخل عنوان URL", "no": "Velg eller sett inn en URL", "tr": "Bir URL seçin veya girin", - "uk": "Виберіть або вставте URL" + "uk": "Виберіть або вставте URL", + "ca": "Seleccioneu o inseriu una URL" }, "TERMINAL$CONSOLE": { "en": "Console", @@ -15805,7 +16792,8 @@ "fr": "Console", "tr": "Konsol", "de": "Konsole", - "uk": "Консоль" + "uk": "Консоль", + "ca": "Consola" }, "MICROAGENT$DEFINITION": { "en": "Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge. They provide expert guidance, automate common tasks, and ensure consistent practices across projects.", @@ -15821,7 +16809,8 @@ "fr": "Les microagents sont des invites spécialisées qui enrichissent OpenHands avec des connaissances spécifiques au domaine. Ils fournissent des conseils d'experts, automatisent les tâches courantes et garantissent des pratiques cohérentes dans les projets.", "tr": "Mikro ajanlar, OpenHands'i alanına özgü bilgilerle geliştiren özel istemlerdir. Uzman rehberliği sağlar, yaygın görevleri otomatikleştirir ve projeler arasında tutarlı uygulamalar sunar.", "de": "Microagents sind spezialisierte Prompts, die OpenHands mit domänenspezifischem Wissen erweitern. Sie bieten fachkundige Anleitung, automatisieren gängige Aufgaben und sorgen für konsistente Praktiken in Projekten.", - "uk": "Мікроагенти — це спеціалізовані підказки, які розширюють OpenHands галузевими знаннями. Вони надають експертні поради, автоматизують типові завдання та забезпечують послідовні практики у проєктах." + "uk": "Мікроагенти — це спеціалізовані підказки, які розширюють OpenHands галузевими знаннями. Вони надають експертні поради, автоматизують типові завдання та забезпечують послідовні практики у проєктах.", + "ca": "Els microagents són missatges especialitzats que milloren OpenHands amb coneixement específic del domini. Proporcionen orientació d'experts, automatitzen tasques habituals i garanteixen pràctiques coherents en tots els projectes." }, "MICROAGENT$ADD_TO_MEMORY": { "en": "Add to Microagent Memory", @@ -15837,7 +16826,8 @@ "fr": "Ajouter à la mémoire du microagent", "tr": "Mikroajan Hafızasına Ekle", "de": "Zur Microagent-Speicher hinzufügen", - "uk": "Додати до пам'яті мікроагента" + "uk": "Додати до пам'яті мікроагента", + "ca": "Afegeix a la memòria del Microagent" }, "COMMON$IN_PROGRESS": { "en": "In Progress", @@ -15853,7 +16843,8 @@ "fr": "En cours", "tr": "Devam Ediyor", "de": "In Bearbeitung", - "uk": "В процесі" + "uk": "В процесі", + "ca": "En curs" }, "SETTINGS$UPGRADE_BANNER_MESSAGE": { "en": "Access LLM settings when you upgrade your plan", @@ -15869,7 +16860,8 @@ "fr": "Accédez aux paramètres LLM lorsque vous mettez à niveau votre plan", "tr": "Planınızı yükselttiğinizde LLM ayarlarına erişin", "de": "Zugriff auf LLM-Einstellungen beim Upgrade Ihres Plans", - "uk": "Отримайте доступ до налаштувань LLM, коли оновите свій план" + "uk": "Отримайте доступ до налаштувань LLM, коли оновите свій план", + "ca": "Accediu a la configuració del LLM quan actualitzeu el vostre pla" }, "SETTINGS$UPGRADE_BUTTON": { "en": "Upgrade", @@ -15885,7 +16877,8 @@ "fr": "Mettre à niveau", "tr": "Yükselt", "de": "Upgrade", - "uk": "Оновити" + "uk": "Оновити", + "ca": "Actualitza" }, "SETTINGS$PRO_PILL": { "en": "Pro", @@ -15901,7 +16894,8 @@ "fr": "Pro", "tr": "Pro", "de": "Pro", - "uk": "Про" + "uk": "Про", + "ca": "Pro" }, "COMMON$STOP_RUNTIME": { "en": "Stop Runtime", @@ -15917,7 +16911,8 @@ "fr": "Arrêter le runtime", "tr": "Çalışma zamanını durdur", "de": "Runtime stoppen", - "uk": "Зупинити середовище виконання" + "uk": "Зупинити середовище виконання", + "ca": "Atura l'entorn d'execució" }, "COMMON$START_RUNTIME": { "en": "Start Runtime", @@ -15933,7 +16928,8 @@ "fr": "Démarrer le runtime", "tr": "Çalışma zamanını başlat", "de": "Runtime starten", - "uk": "Запустити середовище виконання" + "uk": "Запустити середовище виконання", + "ca": "Inicia l'entorn d'execució" }, "COMMON$JUPYTER_EMPTY_MESSAGE": { "en": "Your Jupyter notebook is empty. No cells to display.", @@ -15949,7 +16945,8 @@ "fr": "Votre notebook Jupyter est vide. Aucune cellule à afficher.", "tr": "Jupyter defteriniz boş. Gösterilecek hücre yok.", "de": "Ihr Jupyter-Notebook ist leer. Keine Zellen zum Anzeigen.", - "uk": "Ваш Jupyter-ноутбук порожній. Немає клітинок для відображення." + "uk": "Ваш Jupyter-ноутбук порожній. Немає клітинок для відображення.", + "ca": "El vostre bloc de notes Jupyter és buit. No hi ha cel·les per mostrar." }, "COMMON$CONFIRMATION_MODE_ENABLED": { "en": "Confirmation mode enabled", @@ -15965,7 +16962,8 @@ "fr": "Mode de confirmation activé", "tr": "Onay modu etkinleştirildi", "de": "Bestätigungsmodus aktiviert", - "uk": "Режим підтвердження увімкнено" + "uk": "Режим підтвердження увімкнено", + "ca": "Mode de confirmació activat" }, "COMMON$MOST_RECENT": { "en": "Most Recent", @@ -15981,7 +16979,8 @@ "fr": "Le plus récent", "tr": "En Son", "de": "Neueste", - "uk": "Найновіше" + "uk": "Найновіше", + "ca": "Més recent" }, "HOME$NO_REPOSITORY_FOUND": { "en": "No repository found to launch conversation", @@ -15997,7 +16996,8 @@ "fr": "Aucun dépôt trouvé pour lancer la conversation", "tr": "Konuşma başlatmak için depo bulunamadı", "de": "Kein Repository gefunden, um das Gespräch zu starten", - "uk": "Не знайдено репозиторій для запуску розмови" + "uk": "Не знайдено репозиторій для запуску розмови", + "ca": "No s'ha trobat cap repositori per llançar la conversa" }, "CONVERSATION$VERSION_V1_NEW": { "en": "Conversation API Version 1 (New)", @@ -16013,7 +17013,8 @@ "fr": "API de conversation version 1 (Nouvelle)", "tr": "Konuşma API'si Sürüm 1 (Yeni)", "de": "Konversations-API Version 1 (Neu)", - "uk": "API розмови версія 1 (Нова)" + "uk": "API розмови версія 1 (Нова)", + "ca": "Versió 1 de l'API de conversa (nova)" }, "CONVERSATION$VERSION_V0_LEGACY": { "en": "Conversation API Version 0 (Legacy)", @@ -16029,7 +17030,8 @@ "fr": "API de conversation version 0 (Ancienne)", "tr": "Konuşma API'si Sürüm 0 (Eski)", "de": "Konversations-API Version 0 (Legacy)", - "uk": "API розмови версія 0 (Застаріла)" + "uk": "API розмови версія 0 (Застаріла)", + "ca": "Versió 0 de l'API de conversa (llegat)" }, "CONVERSATION$ERROR_STARTING_CONVERSATION": { "en": "Error starting conversation", @@ -16045,7 +17047,8 @@ "fr": "Erreur lors du démarrage de la conversation", "tr": "Konuşma başlatılırken hata", "de": "Fehler beim Starten der Konversation", - "uk": "Помилка запуску розмови" + "uk": "Помилка запуску розмови", + "ca": "Error en iniciar la conversa" }, "CONVERSATION$READY": { "en": "Ready", @@ -16061,7 +17064,8 @@ "fr": "Prêt", "tr": "Hazır", "de": "Bereit", - "uk": "Готово" + "uk": "Готово", + "ca": "Preparat" }, "CONVERSATION$STARTING_CONVERSATION": { "en": "Starting conversation...", @@ -16077,7 +17081,8 @@ "fr": "Démarrage de la conversation...", "tr": "Konuşma başlatılıyor...", "de": "Konversation wird gestartet...", - "uk": "Запуск розмови..." + "uk": "Запуск розмови...", + "ca": "Iniciant la conversa..." }, "CONVERSATION$FAILED_TO_START_FROM_TASK": { "en": "Failed to start the conversation from task.", @@ -16093,7 +17098,8 @@ "fr": "Échec du démarrage de la conversation depuis la tâche.", "tr": "Görevden konuşma başlatılamadı.", "de": "Konversation konnte nicht aus Aufgabe gestartet werden.", - "uk": "Не вдалося запустити розмову із завдання." + "uk": "Не вдалося запустити розмову із завдання.", + "ca": "No s'ha pogut iniciar la conversa des de la tasca." }, "CONVERSATION$NOT_EXIST_OR_NO_PERMISSION": { "en": "This conversation does not exist, or you do not have permission to access it. If this is your conversation, try switching to the workspace where it was created.", @@ -16109,7 +17115,8 @@ "fr": "Cette conversation n'existe pas ou vous n'avez pas la permission d'y accéder. S'il s'agit de votre conversation, essayez de passer à l'espace de travail où elle a été créée.", "tr": "Bu konuşma mevcut değil veya erişim izniniz yok. Bu sizin konuşmanızsa, oluşturulduğu çalışma alanına geçmeyi deneyin.", "de": "Diese Konversation existiert nicht oder Sie haben keine Berechtigung darauf zuzugreifen. Wenn dies Ihre Konversation ist, versuchen Sie zum Arbeitsbereich zu wechseln, in dem sie erstellt wurde.", - "uk": "Ця розмова не існує або у вас немає дозволу на доступ до неї. Якщо це ваша розмова, спробуйте перейти до робочої області, де вона була створена." + "uk": "Ця розмова не існує або у вас немає дозволу на доступ до неї. Якщо це ваша розмова, спробуйте перейти до робочої області, де вона була створена.", + "ca": "Aquesta conversa no existeix o no teniu permís per accedir-hi." }, "CONVERSATION$FAILED_TO_START_WITH_ERROR": { "en": "Failed to start conversation: {{error}}", @@ -16125,7 +17132,8 @@ "fr": "Échec du démarrage de la conversation : {{error}}", "tr": "Konuşma başlatılamadı: {{error}}", "de": "Konversation konnte nicht gestartet werden: {{error}}", - "uk": "Не вдалося запустити розмову: {{error}}" + "uk": "Не вдалося запустити розмову: {{error}}", + "ca": "No s'ha pogut iniciar la conversa: {{error}}" }, "TOAST$STOPPING_CONVERSATION": { "en": "Stopping conversation...", @@ -16141,7 +17149,8 @@ "fr": "Arrêt de la conversation...", "tr": "Konuşma durduruluyor...", "de": "Konversation wird gestoppt...", - "uk": "Зупинка розмови..." + "uk": "Зупинка розмови...", + "ca": "Aturant la conversa..." }, "TOAST$FAILED_TO_STOP_CONVERSATION": { "en": "Failed to stop conversation", @@ -16157,7 +17166,8 @@ "fr": "Échec de l'arrêt de la conversation", "tr": "Konuşma durdurulamadı", "de": "Konversation konnte nicht gestoppt werden", - "uk": "Не вдалося зупинити розмову" + "uk": "Не вдалося зупинити розмову", + "ca": "No s'ha pogut aturar la conversa" }, "TOAST$CONVERSATION_STOPPED": { "en": "Conversation stopped", @@ -16173,7 +17183,8 @@ "fr": "Conversation arrêtée", "tr": "Konuşma durduruldu", "de": "Konversation gestoppt", - "uk": "Розмову зупинено" + "uk": "Розмову зупинено", + "ca": "Conversa aturada" }, "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION": { "en": "Waiting for user confirmation", @@ -16189,7 +17200,8 @@ "fr": "En attente de la confirmation de l'utilisateur", "tr": "Kullanıcı onayı bekleniyor", "de": "Warte auf Benutzerbestätigung", - "uk": "Очікується підтвердження користувача" + "uk": "Очікується підтвердження користувача", + "ca": "Esperant la confirmació de l'usuari" }, "COMMON$MORE_OPTIONS": { "en": "More options", @@ -16205,7 +17217,8 @@ "fr": "Plus d'options", "tr": "Daha fazla seçenek", "de": "Weitere Optionen", - "uk": "Більше опцій" + "uk": "Більше опцій", + "ca": "Més opcions" }, "COMMON$CREATE_A_PLAN": { "en": "Create a plan", @@ -16221,7 +17234,8 @@ "fr": "Créer un plan", "tr": "Bir plan oluştur", "de": "Einen Plan erstellen", - "uk": "Створити план" + "uk": "Створити план", + "ca": "Crea un pla" }, "COMMON$TASKS": { "en": "Tasks", @@ -16237,7 +17251,8 @@ "fr": "Tâches", "tr": "Görevler", "de": "Aufgaben", - "uk": "Завдання" + "uk": "Завдання", + "ca": "Tasques" }, "COMMON$TASK_LIST": { "en": "Task List", @@ -16253,7 +17268,8 @@ "fr": "Liste des tâches", "tr": "Görev listesi", "de": "Aufgabenliste", - "uk": "Список завдань" + "uk": "Список завдань", + "ca": "Llista de tasques" }, "COMMON$NO_TASKS": { "en": "No tasks yet", @@ -16269,7 +17285,8 @@ "fr": "Aucune tâche pour le moment", "tr": "Henüz görev yok", "de": "Noch keine Aufgaben", - "uk": "Завдань поки немає" + "uk": "Завдань поки немає", + "ca": "Encara no hi ha tasques" }, "COMMON$PLAN_MD": { "en": "Plan.md", @@ -16285,7 +17302,8 @@ "fr": "Plan.md", "tr": "Plan.md", "de": "Plan.md", - "uk": "Plan.md" + "uk": "Plan.md", + "ca": "Pla.md" }, "COMMON$READ_MORE": { "en": "Read more", @@ -16301,7 +17319,8 @@ "fr": "En savoir plus", "tr": "Devamını oku", "de": "Mehr lesen", - "uk": "Читати далі" + "uk": "Читати далі", + "ca": "Llegeix més" }, "COMMON$BUILD": { "en": "Build", @@ -16317,7 +17336,8 @@ "fr": "Construire", "tr": "Derle", "de": "Erstellen", - "uk": "Зібрати" + "uk": "Зібрати", + "ca": "Construeix" }, "COMMON$ASK": { "en": "Ask", @@ -16333,7 +17353,8 @@ "fr": "Demander", "tr": "Sor", "de": "Fragen", - "uk": "Запитати" + "uk": "Запитати", + "ca": "Pregunta" }, "COMMON$PLAN": { "en": "Plan", @@ -16349,7 +17370,8 @@ "fr": "Planifier", "tr": "Plan", "de": "Plan", - "uk": "План" + "uk": "План", + "ca": "Pla" }, "COMMON$LET_S_WORK_ON_A_PLAN": { "en": "Let’s work on a plan", @@ -16365,7 +17387,8 @@ "fr": "Travaillons sur un plan", "tr": "Bir plan üzerinde çalışalım", "de": "Lassen Sie uns an einem Plan arbeiten", - "uk": "Давайте розробимо план" + "uk": "Давайте розробимо план", + "ca": "Treballem en un pla" }, "COMMON$CODE_AGENT_DESCRIPTION": { "en": "Write, edit, and debug with AI assistance in real time.", @@ -16381,7 +17404,8 @@ "fr": "Rédigez, modifiez et déboguez avec l’aide de l’IA en temps réel.", "tr": "AI desteğiyle gerçek zamanlı olarak yazın, düzenleyin ve hata ayıklayın.", "de": "Schreiben, bearbeiten und debuggen Sie mit KI-Unterstützung in Echtzeit.", - "uk": "Пишіть, редагуйте та налагоджуйте з підтримкою ШІ у реальному часі." + "uk": "Пишіть, редагуйте та налагоджуйте з підтримкою ШІ у реальному часі.", + "ca": "Escriu, edita i depura amb assistència d'IA en temps real." }, "COMMON$PLAN_AGENT_DESCRIPTION": { "en": "Outline goals, structure tasks, and map your next steps.", @@ -16397,7 +17421,8 @@ "fr": "Dressez des objectifs, structurez vos tâches et planifiez vos prochaines étapes.", "tr": "Hedefleri belirtin, görevleri yapılandırın ve sonraki adımlarınızı belirleyin.", "de": "Umreißen Sie Ziele, strukturieren Sie Aufgaben und planen Sie Ihre nächsten Schritte.", - "uk": "Окресліть цілі, структуруйте завдання та сплануйте наступні кроки." + "uk": "Окресліть цілі, структуруйте завдання та сплануйте наступні кроки.", + "ca": "Defineix objectius, estructura tasques i planifica els propers passos." }, "PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED": { "en": "Planning agent initialized", @@ -16413,7 +17438,8 @@ "fr": "Agent de planification initialisé", "tr": "Planlama ajanı başlatıldı", "de": "Planungsagent wurde initialisiert", - "uk": "Агент планування ініціалізовано" + "uk": "Агент планування ініціалізовано", + "ca": "Agent de planificació inicialitzat" }, "OBSERVATION_MESSAGE$SKILL_READY": { "en": "Skill Ready", @@ -16429,7 +17455,8 @@ "fr": "Compétence prête", "tr": "Yetenek hazır", "de": "Fähigkeit bereit", - "uk": "Навичка готова" + "uk": "Навичка готова", + "ca": "Habilitat preparada" }, "ORG$ORGANIZATION_NAME": { "en": "Organization Name", @@ -16446,6 +17473,8 @@ "tr": "Organizasyon Adı", "de": "Organisationsname", "uk": "Назва організації" + , + "ca": "Nom de l'organització" }, "ORG$NEXT": { "en": "Next", @@ -16461,7 +17490,8 @@ "fr": "Suivant", "tr": "İleri", "de": "Weiter", - "uk": "Далі" + "uk": "Далі", + "ca": "Següent" }, "ORG$INVITE_USERS_DESCRIPTION": { "en": "Invite colleagues using their email address", @@ -16477,7 +17507,8 @@ "fr": "Invitez des collègues en utilisant leur adresse email", "tr": "E-posta adresi kullanarak meslektaşlarını davet et", "de": "Laden Sie Kollegen per E-Mail-Adresse ein", - "uk": "Запросіть колег за їхньою електронною адресою" + "uk": "Запросіть колег за їхньою електронною адресою", + "ca": "Convideu col·legues mitjançant la seva adreça de correu electrònic" }, "ORG$EMAILS": { "en": "Emails", @@ -16493,7 +17524,8 @@ "fr": "E-mails", "tr": "E-postalar", "de": "E-Mails", - "uk": "Електронні листи" + "uk": "Електронні листи", + "ca": "Correus electrònics" }, "ORG$STATUS_INVITED": { "en": "invited", @@ -16509,7 +17541,8 @@ "fr": "invité", "tr": "davet edildi", "de": "eingeladen", - "uk": "запрошений" + "uk": "запрошений", + "ca": "convidat" }, "ORG$ROLE_ADMIN": { "en": "admin", @@ -16525,7 +17558,8 @@ "fr": "admin", "tr": "yönetici", "de": "Admin", - "uk": "адміністратор" + "uk": "адміністратор", + "ca": "administrador" }, "ORG$ROLE_MEMBER": { "en": "member", @@ -16541,7 +17575,8 @@ "fr": "membre", "tr": "üye", "de": "Mitglied", - "uk": "учасник" + "uk": "учасник", + "ca": "membre" }, "ORG$ROLE_OWNER": { "en": "owner", @@ -16557,7 +17592,8 @@ "fr": "propriétaire", "tr": "sahip", "de": "Eigentümer", - "uk": "власник" + "uk": "власник", + "ca": "propietari" }, "ORG$REMOVE": { "en": "remove", @@ -16573,7 +17609,8 @@ "fr": "supprimer", "tr": "kaldır", "de": "entfernen", - "uk": "видалити" + "uk": "видалити", + "ca": "elimina" }, "ORG$CONFIRM_REMOVE_MEMBER": { "en": "Confirm Remove Member", @@ -16589,7 +17626,8 @@ "fr": "Confirmer la suppression du membre", "tr": "Üye kaldırma onayı", "de": "Mitglied entfernen bestätigen", - "uk": "Підтвердити видалення учасника" + "uk": "Підтвердити видалення учасника", + "ca": "Confirma l'eliminació del membre" }, "ORG$REMOVE_MEMBER_WARNING": { "en": "Are you sure you want to remove {{email}} from this organization? This action cannot be undone.", @@ -16605,7 +17643,8 @@ "fr": "Êtes-vous sûr de vouloir supprimer {{email}} de cette organisation ? Cette action ne peut pas être annulée.", "tr": "{{email}} kullanıcısını bu organizasyondan kaldırmak istediğinizden emin misiniz? Bu işlem geri alınamaz.", "de": "Sind Sie sicher, dass Sie {{email}} aus dieser Organisation entfernen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "uk": "Ви впевнені, що хочете видалити {{email}} з цієї організації? Цю дію неможливо скасувати." + "uk": "Ви впевнені, що хочете видалити {{email}} з цієї організації? Цю дію неможливо скасувати.", + "ca": "Esteu segur que voleu eliminar {{email}} d'aquesta organització? Aquesta acció no es pot desfer." }, "ORG$REMOVE_MEMBER_ERROR": { "en": "Failed to remove member from organization. Please try again.", @@ -16621,7 +17660,8 @@ "fr": "Échec de la suppression du membre de l'organisation. Veuillez réessayer.", "tr": "Üye organizasyondan kaldırılamadı. Lütfen tekrar deneyin.", "de": "Mitglied konnte nicht aus der Organisation entfernt werden. Bitte versuchen Sie es erneut.", - "uk": "Не вдалося видалити учасника з організації. Будь ласка, спробуйте ще раз." + "uk": "Не вдалося видалити учасника з організації. Будь ласка, спробуйте ще раз.", + "ca": "No s'ha pogut eliminar el membre de l'organització. Torneu-ho a intentar." }, "ORG$REMOVE_MEMBER_SUCCESS": { "en": "Member removed successfully", @@ -16637,7 +17677,8 @@ "fr": "Membre supprimé avec succès", "tr": "Üye başarıyla kaldırıldı", "de": "Mitglied erfolgreich entfernt", - "uk": "Учасника успішно видалено" + "uk": "Учасника успішно видалено", + "ca": "Membre eliminat correctament" }, "ORG$CONFIRM_UPDATE_ROLE": { "en": "Confirm Role Update", @@ -16653,7 +17694,8 @@ "fr": "Confirmer la mise à jour du rôle", "tr": "Rol güncellemesini onayla", "de": "Rollenaktualisierung bestätigen", - "uk": "Підтвердити оновлення ролі" + "uk": "Підтвердити оновлення ролі", + "ca": "Confirma l'actualització del rol" }, "ORG$UPDATE_ROLE_WARNING": { "en": "Are you sure you want to change the role of {{email}} to {{role}}?", @@ -16669,7 +17711,8 @@ "fr": "Êtes-vous sûr de vouloir changer le rôle de {{email}} en {{role}} ?", "tr": "{{email}} kullanıcısının rolünü {{role}} olarak değiştirmek istediğinizden emin misiniz?", "de": "Sind Sie sicher, dass Sie die Rolle von {{email}} auf {{role}} ändern möchten?", - "uk": "Ви впевнені, що хочете змінити роль {{email}} на {{role}}?" + "uk": "Ви впевнені, що хочете змінити роль {{email}} на {{role}}?", + "ca": "Esteu segur que voleu canviar el rol de {{email}} a {{role}}?" }, "ORG$UPDATE_ROLE_SUCCESS": { "en": "Role updated successfully", @@ -16685,7 +17728,8 @@ "fr": "Rôle mis à jour avec succès", "tr": "Rol başarıyla güncellendi", "de": "Rolle erfolgreich aktualisiert", - "uk": "Роль успішно оновлено" + "uk": "Роль успішно оновлено", + "ca": "Rol actualitzat correctament" }, "ORG$UPDATE_ROLE_ERROR": { "en": "Failed to update role. Please try again.", @@ -16701,7 +17745,8 @@ "fr": "Échec de la mise à jour du rôle. Veuillez réessayer.", "tr": "Rol güncellenemedi. Lütfen tekrar deneyin.", "de": "Rolle konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.", - "uk": "Не вдалося оновити роль. Будь ласка, спробуйте ще раз." + "uk": "Не вдалося оновити роль. Будь ласка, спробуйте ще раз.", + "ca": "No s'ha pogut actualitzar el rol. Torneu-ho a intentar." }, "ORG$INVITE_MEMBERS_SUCCESS": { "en": "Invitations sent successfully", @@ -16717,7 +17762,8 @@ "fr": "Invitations envoyées avec succès", "tr": "Davetler başarıyla gönderildi", "de": "Einladungen erfolgreich gesendet", - "uk": "Запрошення успішно надіслано" + "uk": "Запрошення успішно надіслано", + "ca": "Invitacions enviades correctament" }, "ORG$INVITE_MEMBERS_ERROR": { "en": "Failed to send invitations. Please try again.", @@ -16733,7 +17779,8 @@ "fr": "Échec de l'envoi des invitations. Veuillez réessayer.", "tr": "Davetler gönderilemedi. Lütfen tekrar deneyin.", "de": "Einladungen konnten nicht gesendet werden. Bitte versuchen Sie es erneut.", - "uk": "Не вдалося надіслати запрошення. Будь ласка, спробуйте ще раз." + "uk": "Не вдалося надіслати запрошення. Будь ласка, спробуйте ще раз.", + "ca": "No s'han pogut enviar les invitacions. Torneu-ho a intentar." }, "ORG$DUPLICATE_EMAILS_ERROR": { "en": "Duplicate email addresses are not allowed", @@ -16749,7 +17796,8 @@ "fr": "Les adresses e-mail en double ne sont pas autorisées", "tr": "Yinelenen e-posta adreslerine izin verilmiyor", "de": "Doppelte E-Mail-Adressen sind nicht erlaubt", - "uk": "Дублікати електронних адрес не допускаються" + "uk": "Дублікати електронних адрес не допускаються", + "ca": "No s'admeten adreces de correu electrònic duplicades" }, "ORG$NO_EMAILS_ADDED_HINT": { "en": "Please type emails and then press space.", @@ -16765,7 +17813,8 @@ "fr": "Veuillez saisir les e-mails puis appuyer sur espace.", "tr": "Lütfen e-postaları yazın ve ardından boşluk tuşuna basın.", "de": "Bitte geben Sie E-Mails ein und drücken Sie dann die Leertaste.", - "uk": "Будь ласка, введіть електронні адреси та натисніть пробіл." + "uk": "Будь ласка, введіть електронні адреси та натисніть пробіл.", + "ca": "Escriviu els correus electrònics i premeu espai." }, "ORG$ACCOUNT": { "en": "Account", @@ -16781,7 +17830,8 @@ "fr": "Compte", "tr": "Hesap", "de": "Konto", - "uk": "Обліковий запис" + "uk": "Обліковий запис", + "ca": "Compte" }, "ORG$INVITE_TEAM": { "en": "Invite Team", @@ -16797,7 +17847,8 @@ "fr": "Inviter l'équipe", "tr": "Takım Davet Et", "de": "Team einladen", - "uk": "Запросити команду" + "uk": "Запросити команду", + "ca": "Convida l'equip" }, "ORG$MANAGE_TEAM": { "en": "Manage Team", @@ -16813,7 +17864,8 @@ "fr": "Gérer l'équipe", "tr": "Takımı Yönet", "de": "Team verwalten", - "uk": "Керувати командою" + "uk": "Керувати командою", + "ca": "Gestiona l'equip" }, "ORG$CHANGE_ORG_NAME": { "en": "Change Organization Name", @@ -16829,7 +17881,8 @@ "fr": "Changer le nom de l'organisation", "tr": "Organizasyon Adını Değiştir", "de": "Organisationsnamen ändern", - "uk": "Змінити назву організації" + "uk": "Змінити назву організації", + "ca": "Canvia el nom de l'organització" }, "ORG$MODIFY_ORG_NAME_DESCRIPTION": { "en": "Modify your Organization Name and Save", @@ -16845,7 +17898,8 @@ "fr": "Modifiez le nom de votre organisation et enregistrez", "tr": "Organizasyon adınızı değiştirin ve kaydedin", "de": "Ändern Sie den Namen Ihrer Organisation und speichern Sie ihn", - "uk": "Змініть назву вашої організації та збережіть" + "uk": "Змініть назву вашої організації та збережіть", + "ca": "Modifiqueu el nom de la vostra organització i deseu" }, "ORG$ADD_CREDITS": { "en": "Add Credits", @@ -16861,7 +17915,8 @@ "fr": "Ajouter des crédits", "tr": "Kredi Ekle", "de": "Credits hinzufügen", - "uk": "Додати кредити" + "uk": "Додати кредити", + "ca": "Afegeix crèdits" }, "ORG$CREDITS": { "en": "Credits", @@ -16877,7 +17932,8 @@ "fr": "Crédits", "tr": "Krediler", "de": "Credits", - "uk": "Кредити" + "uk": "Кредити", + "ca": "Crèdits" }, "ORG$ADD": { "en": "+ Add", @@ -16893,7 +17949,8 @@ "fr": "+ Ajouter", "tr": "+ Ekle", "de": "+ Hinzufügen", - "uk": "+ Додати" + "uk": "+ Додати", + "ca": "+ Afegeix" }, "ORG$BILLING_INFORMATION": { "en": "Billing Information", @@ -16909,7 +17966,8 @@ "fr": "Informations de facturation", "tr": "Fatura Bilgisi", "de": "Rechnungsinformationen", - "uk": "Платіжна інформація" + "uk": "Платіжна інформація", + "ca": "Informació de facturació" }, "ORG$CHANGE": { "en": "Change", @@ -16925,7 +17983,8 @@ "fr": "Modifier", "tr": "Değiştir", "de": "Ändern", - "uk": "Змінити" + "uk": "Змінити", + "ca": "Canvia" }, "ORG$DELETE_ORGANIZATION": { "en": "Delete Organization", @@ -16941,7 +18000,8 @@ "fr": "Supprimer l'organisation", "tr": "Organizasyonu Sil", "de": "Organisation löschen", - "uk": "Видалити організацію" + "uk": "Видалити організацію", + "ca": "Elimina l'organització" }, "ORG$DELETE_ORGANIZATION_WARNING": { "en": "Are you sure you want to delete this organization? This action cannot be undone.", @@ -16957,7 +18017,8 @@ "fr": "Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action ne peut pas être annulée.", "tr": "Bu organizasyonu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "de": "Sind Sie sicher, dass Sie diese Organisation löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "uk": "Ви впевнені, що хочете видалити цю організацію? Цю дію не можна скасувати." + "uk": "Ви впевнені, що хочете видалити цю організацію? Цю дію не можна скасувати.", + "ca": "Esteu segur que voleu eliminar aquesta organització? Aquesta acció no es pot desfer." }, "ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME": { "en": "Are you sure you want to delete the \"{{name}}\" organization? This action cannot be undone.", @@ -16973,7 +18034,8 @@ "fr": "Êtes-vous sûr de vouloir supprimer l'organisation \\u00AB {{name}} \\u00BB ? Cette action ne peut pas être annulée.", "tr": "\"{{name}}\" organizasyonunu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "de": "Sind Sie sicher, dass Sie die Organisation \\u201E{{name}}\\u201C löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "uk": "Ви впевнені, що хочете видалити організацію \\u00AB{{name}}\\u00BB? Цю дію не можна скасувати." + "uk": "Ви впевнені, що хочете видалити організацію \\u00AB{{name}}\\u00BB? Цю дію не можна скасувати.", + "ca": "Esteu segur que voleu eliminar l'organització \"{{name}}\"? Aquesta acció no es pot desfer." }, "ORG$DELETE_ORGANIZATION_ERROR": { "en": "Failed to delete organization", @@ -16989,7 +18051,8 @@ "fr": "Échec de la suppression de l'organisation", "tr": "Organizasyon silinemedi", "de": "Organisation konnte nicht gelöscht werden", - "uk": "Не вдалося видалити організацію" + "uk": "Не вдалося видалити організацію", + "ca": "No s'ha pogut eliminar l'organització" }, "ACCOUNT_SETTINGS$SETTINGS": { "en": "Settings", @@ -17005,7 +18068,8 @@ "fr": "Paramètres", "tr": "Ayarlar", "de": "Einstellungen", - "uk": "Налаштування" + "uk": "Налаштування", + "ca": "Configuració" }, "ORG$MANAGE_ORGANIZATION_MEMBERS": { "en": "Manage Organization Members", @@ -17021,7 +18085,8 @@ "fr": "Gérer les membres de l'organisation", "tr": "Organizasyon Üyelerini Yönet", "de": "Organisationsmitglieder verwalten", - "uk": "Керувати учасниками організації" + "uk": "Керувати учасниками організації", + "ca": "Gestiona els membres de l'organització" }, "ORG$SELECT_ORGANIZATION_PLACEHOLDER": { "en": "Please select an organization", @@ -17037,7 +18102,8 @@ "fr": "Veuillez sélectionner une organisation", "tr": "Lütfen bir organizasyon seçin", "de": "Bitte wählen Sie eine Organisation", - "uk": "Будь ласка, виберіть організацію" + "uk": "Будь ласка, виберіть організацію", + "ca": "Seleccioneu una organització" }, "ORG$PERSONAL_WORKSPACE": { "en": "Personal Workspace", @@ -17053,7 +18119,8 @@ "fr": "Espace de travail personnel", "tr": "Kişisel çalışma alanı", "de": "Persönlicher Arbeitsbereich", - "uk": "Особистий робочий простір" + "uk": "Особистий робочий простір", + "ca": "Espai de treball personal" }, "ORG$ENTER_NEW_ORGANIZATION_NAME": { "en": "Enter new organization name", @@ -17069,7 +18136,8 @@ "fr": "Entrez le nouveau nom de l'organisation", "tr": "Yeni organizasyon adını girin", "de": "Geben Sie den neuen Organisationsnamen ein", - "uk": "Введіть нову назву організації" + "uk": "Введіть нову назву організації", + "ca": "Introduïu el nou nom de l'organització" }, "CONVERSATION$SHOW_SKILLS": { "en": "Show Available Skills", @@ -17085,7 +18153,8 @@ "pt": "Mostrar habilidades disponíveis", "es": "Mostrar habilidades disponibles", "tr": "Kullanılabilir yetenekleri göster", - "uk": "Показати доступні навички" + "uk": "Показати доступні навички", + "ca": "Mostra les habilitats disponibles" }, "SKILLS_MODAL$TITLE": { "en": "Available Skills", @@ -17101,7 +18170,8 @@ "pt": "Habilidades disponíveis", "es": "Habilidades disponibles", "tr": "Kullanılabilir yetenekler", - "uk": "Доступні навички" + "uk": "Доступні навички", + "ca": "Habilitats disponibles" }, "CONVERSATION$SHARE_PUBLICLY": { "en": "Public Share", @@ -17117,7 +18187,8 @@ "fr": "Partager publiquement", "tr": "Herkese açık paylaş", "de": "Öffentlich teilen", - "uk": "Поділитися публічно" + "uk": "Поділитися публічно", + "ca": "Comparteix públicament" }, "CONVERSATION$PUBLIC_SHARING_UPDATED": { "en": "Public sharing updated", @@ -17133,7 +18204,8 @@ "fr": "Partage public mis à jour", "tr": "Herkese açık paylaşım güncellendi", "de": "Öffentliche Freigabe aktualisiert", - "uk": "Публічний доступ оновлено" + "uk": "Публічний доступ оновлено", + "ca": "Compartició pública actualitzada" }, "CONVERSATION$FAILED_TO_UPDATE_PUBLIC_SHARING": { "en": "Failed to update public sharing", @@ -17149,7 +18221,8 @@ "fr": "Échec de la mise à jour du partage public", "tr": "Herkese açık paylaşım güncellenemedi", "de": "Fehler beim Aktualisieren der öffentlichen Freigabe", - "uk": "Не вдалося оновити публічний доступ" + "uk": "Не вдалося оновити публічний доступ", + "ca": "No s'ha pogut actualitzar la compartició pública" }, "CONVERSATION$REPOSITORY_UPDATED": { "en": "Repository updated successfully", @@ -17165,7 +18238,8 @@ "fr": "Dépôt mis à jour avec succès", "tr": "Depo başarıyla güncellendi", "de": "Repository erfolgreich aktualisiert", - "uk": "Репозиторій успішно оновлено" + "uk": "Репозиторій успішно оновлено", + "ca": "Repositori actualitzat correctament" }, "CONVERSATION$FAILED_TO_UPDATE_REPOSITORY": { "en": "Failed to update repository", @@ -17181,7 +18255,8 @@ "fr": "Échec de la mise à jour du dépôt", "tr": "Depo güncellenemedi", "de": "Fehler beim Aktualisieren des Repositorys", - "uk": "Не вдалося оновити репозиторій" + "uk": "Не вдалося оновити репозиторій", + "ca": "No s'ha pogut actualitzar el repositori" }, "CONVERSATION$CLONE_COMMAND_FAILED_DISCONNECTED": { "en": "Repository updated but clone command could not be sent. Please reconnect and manually clone.", @@ -17197,7 +18272,8 @@ "fr": "Dépôt mis à jour mais la commande de clonage n'a pas pu être envoyée. Reconnectez-vous et clonez manuellement.", "tr": "Depo güncellendi ancak klonlama komutu gönderilemedi. Lütfen yeniden bağlanın ve manuel olarak klonlayın.", "de": "Repository aktualisiert, aber Klon-Befehl konnte nicht gesendet werden. Bitte erneut verbinden und manuell klonen.", - "uk": "Репозиторій оновлено, але команду клонування не вдалося надіслати. Підключіться знову та клонуйте вручну." + "uk": "Репозиторій оновлено, але команду клонування не вдалося надіслати. Підключіться знову та клонуйте вручну.", + "ca": "Repositori actualitzat però no s'ha pogut enviar la comanda de clonació. Reconnecteu i cloneu manualment." }, "CONVERSATION$CHANGE_REPOSITORY": { "en": "Change Repository", @@ -17213,7 +18289,8 @@ "fr": "Changer de dépôt", "tr": "Depoyu değiştir", "de": "Repository ändern", - "uk": "Змінити репозиторій" + "uk": "Змінити репозиторій", + "ca": "Canvia el repositori" }, "CONVERSATION$ATTACH_REPOSITORY": { "en": "Attach Repository", @@ -17229,7 +18306,8 @@ "fr": "Attacher un dépôt", "tr": "Depo ekle", "de": "Repository anhängen", - "uk": "Прикріпити репозиторій" + "uk": "Прикріпити репозиторій", + "ca": "Adjunta el repositori" }, "CONVERSATION$OPEN_REPOSITORY": { "en": "Open Repository", @@ -17245,7 +18323,8 @@ "fr": "Ouvrir le dépôt", "tr": "Depoyu aç", "de": "Repository öffnen", - "uk": "Відкрити репозиторій" + "uk": "Відкрити репозиторій", + "ca": "Obre el repositori" }, "CONVERSATION$SELECT_OR_INSERT_LINK": { "en": "Select or insert a link", @@ -17261,7 +18340,8 @@ "fr": "Sélectionner ou insérer un lien", "tr": "Bir bağlantı seçin veya ekleyin", "de": "Link auswählen oder einfügen", - "uk": "Виберіть або вставте посилання" + "uk": "Виберіть або вставте посилання", + "ca": "Seleccioneu o inseriu un enllaç" }, "CONVERSATION$NO_REPO_CONNECTED": { "en": "No Repo Connected", @@ -17277,7 +18357,8 @@ "fr": "Aucun dépôt connecté", "tr": "Bağlı depo yok", "de": "Kein Repository verbunden", - "uk": "Репозиторій не підключено" + "uk": "Репозиторій не підключено", + "ca": "Sense repositori connectat" }, "CONVERSATION$NOT_FOUND": { "en": "Conversation not found", @@ -17293,7 +18374,8 @@ "fr": "Conversation introuvable", "tr": "Konuşma bulunamadı", "de": "Unterhaltung nicht gefunden", - "uk": "Розмову не знайдено" + "uk": "Розмову не знайдено", + "ca": "Conversa no trobada" }, "CONVERSATION$NO_HISTORY_AVAILABLE": { "en": "No conversation history available", @@ -17309,7 +18391,8 @@ "fr": "Aucun historique de conversation disponible", "tr": "Kullanılabilir konuşma geçmişi yok", "de": "Keine Unterhaltungshistorie verfügbar", - "uk": "Історія розмов недоступна" + "uk": "Історія розмов недоступна", + "ca": "No hi ha historial de conversa disponible" }, "CONVERSATION$SHARED_CONVERSATION": { "en": "Shared Conversation", @@ -17325,7 +18408,8 @@ "fr": "Conversation publique", "tr": "Herkese açık konuşma", "de": "Öffentliche Unterhaltung", - "uk": "Публічна розмова" + "uk": "Публічна розмова", + "ca": "Conversa compartida" }, "CONVERSATION$LINK_COPIED": { "en": "Link copied to clipboard", @@ -17341,7 +18425,144 @@ "fr": "Lien copié dans le presse-papiers", "tr": "Bağlantı panoya kopyalandı", "de": "Link in die Zwischenablage kopiert", - "uk": "Посилання скопійовано в буфер обміну" + "uk": "Посилання скопійовано в буфер обміну", + "ca": "Enllaç copiat al porta-retalls" + }, + "ONBOARDING$STEP1_TITLE": { + "en": "What's your role?", + "ja": "あなたの役割は?", + "zh-CN": "您的角色是什么?", + "zh-TW": "您的角色是什麼?", + "ko-KR": "귀하의 역할은 무엇입니까?", + "no": "Hva er din rolle?", + "ar": "ما هو دورك؟", + "de": "Was ist Ihre Rolle?", + "fr": "Quel est votre rôle ?", + "it": "Qual è il tuo ruolo?", + "pt": "Qual é o seu papel?", + "es": "¿Cuál es tu rol?", + "tr": "Rolünüz nedir?", + "uk": "Яка ваша роль?", + "ca": "Quin és el vostre rol?" + }, + "ONBOARDING$STEP1_SUBTITLE": { + "en": "Select the option that best fits you", + "ja": "最も当てはまるオプションを選択してください", + "zh-CN": "选择最适合您的选项", + "zh-TW": "選擇最適合您的選項", + "ko-KR": "가장 적합한 옵션을 선택하세요", + "no": "Velg alternativet som passer deg best", + "ar": "اختر الخيار الأنسب لك", + "de": "Wählen Sie die Option, die am besten zu Ihnen passt", + "fr": "Sélectionnez l'option qui vous convient le mieux", + "it": "Seleziona l'opzione più adatta a te", + "pt": "Selecione a opção que melhor se adapta a você", + "es": "Selecciona la opción que mejor te describa", + "tr": "Size en uygun seçeneği seçin", + "uk": "Виберіть варіант, який найкраще вам підходить", + "ca": "Seleccioneu l'opció que millor us descrigui" + }, + "ONBOARDING$SOFTWARE_ENGINEER": { + "en": "Software engineer / developer", + "ja": "ソフトウェアエンジニア / 開発者", + "zh-CN": "软件工程师 / 开发者", + "zh-TW": "軟體工程師 / 開發者", + "ko-KR": "소프트웨어 엔지니어 / 개발자", + "no": "Programvareingeniør / utvikler", + "ar": "مهندس برمجيات / مطور", + "de": "Softwareentwickler / Entwickler", + "fr": "Ingénieur logiciel / développeur", + "it": "Ingegnere software / sviluppatore", + "pt": "Engenheiro de software / desenvolvedor", + "es": "Ingeniero de software / desarrollador", + "tr": "Yazılım mühendisi / geliştirici", + "uk": "Програмний інженер / розробник", + "ca": "Enginyer/a de programari / desenvolupador/a" + }, + "ONBOARDING$ENGINEERING_MANAGER": { + "en": "Engineering manager / tech lead", + "ja": "エンジニアリングマネージャー / テックリード", + "zh-CN": "工程经理 / 技术负责人", + "zh-TW": "工程經理 / 技術負責人", + "ko-KR": "엔지니어링 매니저 / 테크 리드", + "no": "Ingeniørsjef / teknisk leder", + "ar": "مدير هندسة / قائد تقني", + "de": "Engineering Manager / Tech Lead", + "fr": "Responsable ingénierie / tech lead", + "it": "Engineering manager / tech lead", + "pt": "Gerente de engenharia / tech lead", + "es": "Gerente de ingeniería / tech lead", + "tr": "Mühendislik müdürü / teknik lider", + "uk": "Менеджер з розробки / технічний лідер", + "ca": "Responsable d'enginyeria / líder tècnic/a" + }, + "ONBOARDING$CTO_FOUNDER": { + "en": "CTO / founder", + "ja": "CTO / 創業者", + "zh-CN": "CTO / 创始人", + "zh-TW": "CTO / 創辦人", + "ko-KR": "CTO / 창업자", + "no": "CTO / grunnlegger", + "ar": "مدير التكنولوجيا / مؤسس", + "de": "CTO / Gründer", + "fr": "CTO / fondateur", + "it": "CTO / fondatore", + "pt": "CTO / fundador", + "es": "CTO / fundador", + "tr": "CTO / kurucu", + "uk": "CTO / засновник", + "ca": "CTO / fundador/a" + }, + "ONBOARDING$PRODUCT_OPERATIONS": { + "en": "Product or operations role", + "ja": "プロダクトまたはオペレーションの役割", + "zh-CN": "产品或运营角色", + "zh-TW": "產品或營運角色", + "ko-KR": "제품 또는 운영 역할", + "no": "Produkt- eller driftsrolle", + "ar": "دور المنتج أو العمليات", + "de": "Produkt- oder Betriebsrolle", + "fr": "Rôle produit ou opérations", + "it": "Ruolo prodotto o operazioni", + "pt": "Função de produto ou operações", + "es": "Rol de producto u operaciones", + "tr": "Ürün veya operasyon rolü", + "uk": "Роль продукту або операцій", + "ca": "Rol de producte o operacions" + }, + "ONBOARDING$STUDENT_HOBBYIST": { + "en": "Student / hobbyist", + "ja": "学生 / 趣味", + "zh-CN": "学生 / 爱好者", + "zh-TW": "學生 / 愛好者", + "ko-KR": "학생 / 취미", + "no": "Student / hobbyist", + "ar": "طالب / هاوٍ", + "de": "Student / Hobbyist", + "fr": "Étudiant / amateur", + "it": "Studente / hobbista", + "pt": "Estudante / hobbyista", + "es": "Estudiante / aficionado", + "tr": "Öğrenci / hobi", + "uk": "Студент / хобіст", + "ca": "Estudiant / aficionat/da" + }, + "ONBOARDING$OTHER": { + "en": "Other", + "ja": "その他", + "zh-CN": "其他", + "zh-TW": "其他", + "ko-KR": "기타", + "no": "Annet", + "ar": "أخرى", + "de": "Andere", + "fr": "Autre", + "it": "Altro", + "pt": "Outro", + "es": "Otro", + "tr": "Diğer", + "uk": "Інше", + "ca": "Altre" }, "HOOKS_MODAL$TITLE": { "en": "Available Hooks", @@ -17357,7 +18578,8 @@ "pt": "Hooks disponíveis", "es": "Hooks disponibles", "tr": "Kullanılabilir kancalar", - "uk": "Доступні хуки" + "uk": "Доступні хуки", + "ca": "Hooks disponibles" }, "HOOKS_MODAL$WARNING": { "en": "Hooks are loaded from your workspace. This view refreshes on demand and may differ from the hooks that were active when the conversation started. Stop and restart the conversation to apply changes.", @@ -17373,7 +18595,8 @@ "pt": "Os hooks são carregados do seu workspace. Esta visualização é atualizada sob demanda a partir do workspace e pode ser diferente dos hooks que estavam ativos quando a conversa foi iniciada. Pare e reinicie a conversa para aplicar as alterações.", "es": "Los hooks se cargan desde tu espacio de trabajo. Esta vista se actualiza bajo demanda desde el workspace y puede diferir de los hooks que estaban activos cuando comenzó la conversación. Detén y reinicia la conversación para aplicar los cambios.", "tr": "Kancalar çalışma alanınızdan yüklenir. Bu görünüm istek üzerine çalışma alanından yenilenir ve sohbet başlatıldığında etkin olan kancalardan farklı olabilir. Değişiklikleri uygulamak için sohbeti durdurup yeniden başlatın.", - "uk": "Хуки завантажуються з вашого робочого простору. Це подання оновлюється з робочого простору на вимогу й може відрізнятися від хуків, які були активні під час запуску розмови. Щоб застосувати зміни, зупиніть і перезапустіть розмову." + "uk": "Хуки завантажуються з вашого робочого простору. Це подання оновлюється з робочого простору на вимогу й може відрізнятися від хуків, які були активні під час запуску розмови. Щоб застосувати зміни, зупиніть і перезапустіть розмову.", + "ca": "Els hooks es carreguen des del vostre espai de treball. Aquesta vista s'actualitza a demanda i pot diferir dels hooks que estaven actius quan va començar la conversa. Atureu i reinicieu la conversa per aplicar els canvis." }, "HOOKS_MODAL$MATCHER": { "en": "Matcher", @@ -17389,7 +18612,8 @@ "pt": "Matcher", "es": "Matcher", "tr": "Eşleştirici", - "uk": "Матчер" + "uk": "Матчер", + "ca": "Coincidència" }, "HOOKS_MODAL$COMMANDS": { "en": "Commands", @@ -17405,7 +18629,8 @@ "pt": "Comandos", "es": "Comandos", "tr": "Komutlar", - "uk": "Команди" + "uk": "Команди", + "ca": "Comandes" }, "HOOKS_MODAL$HOOK_COUNT": { "en": "{{count}} hook(s)", @@ -17421,7 +18646,8 @@ "pt": "{{count}} hook", "es": "{{count}} hook", "tr": "{{count}} kanca", - "uk": "{{count}} хук" + "uk": "{{count}} хук", + "ca": "{{count}} hook(s)" }, "HOOKS_MODAL$TYPE": { "en": "Type: {{type}}", @@ -17437,7 +18663,8 @@ "pt": "Tipo: {{type}}", "es": "Tipo: {{type}}", "tr": "Tür: {{type}}", - "uk": "Тип: {{type}}" + "uk": "Тип: {{type}}", + "ca": "Tipus: {{type}}" }, "HOOKS_MODAL$TIMEOUT": { "en": "Timeout: {{timeout}}s", @@ -17453,7 +18680,8 @@ "pt": "Tempo limite: {{timeout}}s", "es": "Tiempo de espera: {{timeout}}s", "tr": "Zaman aşımı: {{timeout}}s", - "uk": "Таймаут: {{timeout}}с" + "uk": "Таймаут: {{timeout}}с", + "ca": "Temps d'espera: {{timeout}}s" }, "HOOKS_MODAL$ASYNC": { "en": "Async", @@ -17469,7 +18697,8 @@ "pt": "Assíncrono", "es": "Asíncrono", "tr": "Asenkron", - "uk": "Асинхронний" + "uk": "Асинхронний", + "ca": "Asíncron" }, "HOOKS_MODAL$EVENT_PRE_TOOL_USE": { "en": "Pre Tool Use", @@ -17485,7 +18714,8 @@ "pt": "Antes do uso da ferramenta", "es": "Antes del uso de la herramienta", "tr": "Araç kullanımı öncesi", - "uk": "Перед використанням інструменту" + "uk": "Перед використанням інструменту", + "ca": "Abans d'usar l'eina" }, "HOOKS_MODAL$EVENT_POST_TOOL_USE": { "en": "Post Tool Use", @@ -17501,7 +18731,8 @@ "pt": "Após o uso da ferramenta", "es": "Después del uso de la herramienta", "tr": "Araç kullanımı sonrası", - "uk": "Після використання інструменту" + "uk": "Після використання інструменту", + "ca": "Després d'usar l'eina" }, "HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT": { "en": "User Prompt Submit", @@ -17517,7 +18748,8 @@ "pt": "Envio de prompt do usuário", "es": "Envío de solicitud del usuario", "tr": "Kullanıcı istemi gönderimi", - "uk": "Надсилання запиту користувача" + "uk": "Надсилання запиту користувача", + "ca": "Enviament del missatge de l'usuari" }, "HOOKS_MODAL$EVENT_SESSION_START": { "en": "Session Start", @@ -17533,7 +18765,8 @@ "pt": "Início da sessão", "es": "Inicio de sesión", "tr": "Oturum başlangıcı", - "uk": "Початок сесії" + "uk": "Початок сесії", + "ca": "Inici de sessió" }, "HOOKS_MODAL$EVENT_SESSION_END": { "en": "Session End", @@ -17549,7 +18782,8 @@ "pt": "Fim da sessão", "es": "Fin de sesión", "tr": "Oturum sonu", - "uk": "Кінець сесії" + "uk": "Кінець сесії", + "ca": "Fi de sessió" }, "HOOKS_MODAL$EVENT_STOP": { "en": "Stop", @@ -17565,7 +18799,8 @@ "pt": "Parar", "es": "Detener", "tr": "Durdur", - "uk": "Зупинка" + "uk": "Зупинка", + "ca": "Atura" }, "HOOK$HOOK_LABEL": { "en": "Hook", @@ -17581,7 +18816,8 @@ "pt": "Hook", "es": "Gancho", "tr": "Kanca", - "uk": "Хук" + "uk": "Хук", + "ca": "Hook" }, "HOOK$COMMAND": { "en": "Command", @@ -17597,7 +18833,8 @@ "pt": "Comando", "es": "Comando", "tr": "Komut", - "uk": "Команда" + "uk": "Команда", + "ca": "Comanda" }, "HOOK$EXIT_CODE": { "en": "Exit code", @@ -17613,7 +18850,8 @@ "pt": "Código de saída", "es": "Código de salida", "tr": "Çıkış kodu", - "uk": "Код виходу" + "uk": "Код виходу", + "ca": "Codi de sortida" }, "HOOK$BLOCKED_REASON": { "en": "Blocked reason", @@ -17629,7 +18867,8 @@ "pt": "Motivo do bloqueio", "es": "Motivo del bloqueo", "tr": "Engelleme nedeni", - "uk": "Причина блокування" + "uk": "Причина блокування", + "ca": "Motiu de bloqueig" }, "HOOK$CONTEXT": { "en": "Context", @@ -17645,7 +18884,8 @@ "pt": "Contexto", "es": "Contexto", "tr": "Bağlam", - "uk": "Контекст" + "uk": "Контекст", + "ca": "Context" }, "HOOK$ERROR": { "en": "Error", @@ -17662,6 +18902,8 @@ "es": "Error", "tr": "Hata", "uk": "Помилка" + , + "ca": "Error" }, "HOOK$OUTPUT": { "en": "Output", @@ -17677,7 +18919,8 @@ "pt": "Saída", "es": "Salida", "tr": "Çıktı", - "uk": "Вивід" + "uk": "Вивід", + "ca": "Sortida" }, "HOOK$STDERR": { "en": "Stderr", @@ -17693,7 +18936,8 @@ "pt": "Erro padrão", "es": "Error estándar", "tr": "Standart hata", - "uk": "Стандартна помилка" + "uk": "Стандартна помилка", + "ca": "Stderr" }, "COMMON$TYPE_EMAIL_AND_PRESS_SPACE": { "en": "Type email and press Space", @@ -17709,7 +18953,8 @@ "fr": "Tapez l'e-mail et appuyez sur Espace", "tr": "E-postu yazıp Boşluk tuşuna basın", "de": "E-Mail eingeben und Leertaste drücken", - "uk": "Введіть e-mail і натисніть Пробіл" + "uk": "Введіть e-mail і натисніть Пробіл", + "ca": "Escriviu el correu electrònic i premeu Espai" }, "ORG$INVITE_ORG_MEMBERS": { "en": "Invite Organization Members", @@ -17725,7 +18970,8 @@ "fr": "Inviter des membres de l'organisation", "tr": "Organizasyon üyelerini davet et", "de": "Organisationsmitglieder einladen", - "uk": "Запросити членів організації" + "uk": "Запросити членів організації", + "ca": "Convida membres de l'organització" }, "ORG$MANAGE_ORGANIZATION": { "en": "Manage Organization", @@ -17741,7 +18987,8 @@ "fr": "Gérer l'organisation", "tr": "Organizasyonu yönet", "de": "Organisation verwalten", - "uk": "Керувати організацією" + "uk": "Керувати організацією", + "ca": "Gestiona l'organització" }, "ORG$ORGANIZATION_MEMBERS": { "en": "Organization Members", @@ -17757,7 +19004,8 @@ "fr": "Membres de l'organisation", "tr": "Organizasyon Üyeleri", "de": "Organisationsmitglieder", - "uk": "Члени організації" + "uk": "Члени організації", + "ca": "Membres de l'organització" }, "ORG$ALL_ORGANIZATION_MEMBERS": { "en": "All Organization Members", @@ -17773,7 +19021,8 @@ "fr": "Tous les membres de l'organisation", "tr": "Tüm organizasyon üyeleri", "de": "Alle Organisationsmitglieder", - "uk": "Усі члени організації" + "uk": "Усі члени організації", + "ca": "Tots els membres de l'organització" }, "ORG$SEARCH_BY_EMAIL": { "en": "Search by email...", @@ -17789,7 +19038,8 @@ "fr": "Rechercher par e-mail...", "tr": "E-posta ile ara...", "de": "Nach E-Mail suchen...", - "uk": "Пошук за електронною поштою..." + "uk": "Пошук за електронною поштою...", + "ca": "Cerca per correu electrònic..." }, "ORG$NO_MEMBERS_FOUND": { "en": "No members found", @@ -17805,7 +19055,8 @@ "fr": "Aucun membre trouvé", "tr": "Üye bulunamadı", "de": "Keine Mitglieder gefunden", - "uk": "Членів не знайдено" + "uk": "Членів не знайдено", + "ca": "No s'han trobat membres" }, "ORG$NO_MEMBERS_MATCHING_FILTER": { "en": "No members match your search", @@ -17821,7 +19072,8 @@ "fr": "Aucun membre ne correspond à votre recherche", "tr": "Aramanızla eşleşen üye bulunamadı", "de": "Keine Mitglieder entsprechen Ihrer Suche", - "uk": "Жодний член не відповідає вашому пошуку" + "uk": "Жодний член не відповідає вашому пошуку", + "ca": "Cap membre coincideix amb la vostra cerca" }, "ORG$FAILED_TO_LOAD_MEMBERS": { "en": "Failed to load members", @@ -17837,7 +19089,8 @@ "fr": "Échec du chargement des membres", "tr": "Üyeler yüklenemedi", "de": "Mitglieder konnten nicht geladen werden", - "uk": "Не вдалося завантажити членів" + "uk": "Не вдалося завантажити членів", + "ca": "No s'han pogut carregar els membres" }, "ONBOARDING$ORG_NAME_QUESTION": { "en": "What's the name of your organization?", @@ -17853,7 +19106,8 @@ "pt": "Qual é o nome da sua organização?", "es": "¿Cuál es el nombre de tu organización?", "tr": "Organizasyonunuzun adı nedir?", - "uk": "Як називається ваша організація?" + "uk": "Як називається ваша організація?", + "ca": "Com es diu la vostra organització?" }, "ONBOARDING$ORG_NAME_INPUT_NAME": { "en": "Org name", @@ -17870,6 +19124,8 @@ "es": "Nombre de la organización", "tr": "Organizasyon adı", "uk": "Назва організації" + , + "ca": "Nom de l'organització" }, "ONBOARDING$ORG_NAME_INPUT_DOMAIN": { "en": "Domain name", @@ -17885,7 +19141,8 @@ "pt": "Nome de domínio", "es": "Nombre de dominio", "tr": "Alan adı", - "uk": "Доменне ім'я" + "uk": "Доменне ім'я", + "ca": "Nom de domini" }, "ONBOARDING$ORG_SIZE_QUESTION": { "en": "What size organization do you work for?", @@ -17901,7 +19158,8 @@ "pt": "Qual o tamanho da organização em que você trabalha?", "es": "¿De qué tamaño es la organización para la que trabajas?", "tr": "Hangi büyüklükte bir organizasyon için çalışıyorsunuz?", - "uk": "Якого розміру організація, в якій ви працюєте?" + "uk": "Якого розміру організація, в якій ви працюєте?", + "ca": "De quina mida és la vostra organització?" }, "ONBOARDING$ORG_SIZE_SUBTITLE": { "en": "Select one", @@ -17917,7 +19175,8 @@ "pt": "Selecione uma opção", "es": "Seleccione una opción", "tr": "Bir seçenek seçin", - "uk": "Виберіть один" + "uk": "Виберіть один", + "ca": "Seleccioneu-ne un" }, "ONBOARDING$ORG_SIZE_SOLO": { "en": "Just me (solo)", @@ -17933,7 +19192,8 @@ "pt": "Apenas eu (solo)", "es": "Solo yo (individual)", "tr": "Sadece ben (solo)", - "uk": "Тільки я (соло)" + "uk": "Тільки я (соло)", + "ca": "Només jo (individual)" }, "ONBOARDING$ORG_SIZE_2_10": { "en": "2–10 people", @@ -17949,7 +19209,8 @@ "pt": "2–10 pessoas", "es": "2–10 personas", "tr": "2–10 kişi", - "uk": "2–10 осіб" + "uk": "2–10 осіб", + "ca": "2-10 persones" }, "ONBOARDING$ORG_SIZE_11_50": { "en": "11–50 people", @@ -17965,7 +19226,8 @@ "pt": "11–50 pessoas", "es": "11–50 personas", "tr": "11–50 kişi", - "uk": "11–50 осіб" + "uk": "11–50 осіб", + "ca": "11-50 persones" }, "ONBOARDING$ORG_SIZE_51_200": { "en": "51–200 people", @@ -17981,7 +19243,8 @@ "pt": "51–200 pessoas", "es": "51–200 personas", "tr": "51–200 kişi", - "uk": "51–200 осіб" + "uk": "51–200 осіб", + "ca": "51-200 persones" }, "ONBOARDING$ORG_SIZE_200_PLUS": { "en": "200+ people", @@ -17997,7 +19260,8 @@ "pt": "200+ pessoas", "es": "200+ personas", "tr": "200+ kişi", - "uk": "200+ осіб" + "uk": "200+ осіб", + "ca": "Més de 200 persones" }, "ONBOARDING$USE_CASE_QUESTION": { "en": "What use cases are you looking to use OpenHands for?", @@ -18013,7 +19277,8 @@ "pt": "Para quais casos de uso você pretende usar o OpenHands?", "es": "¿Para qué casos de uso quieres usar OpenHands?", "tr": "OpenHands'i hangi kullanım alanları için kullanmak istiyorsunuz?", - "uk": "Для яких випадків використання ви хочете використовувати OpenHands?" + "uk": "Для яких випадків використання ви хочете використовувати OpenHands?", + "ca": "Per a quins casos d'ús voleu fer servir OpenHands?" }, "ONBOARDING$USE_CASE_SUBTITLE": { "en": "Check all that apply", @@ -18029,7 +19294,8 @@ "pt": "Selecione todas as opções aplicáveis", "es": "Selecciona todas las que apliquen", "tr": "Geçerli olanların tümünü seçin", - "uk": "Виберіть усі, що стосуються" + "uk": "Виберіть усі, що стосуються", + "ca": "Marqueu tot el que correspongui" }, "ONBOARDING$USE_CASE_NEW_FEATURES": { "en": "Writing new features to existing products", @@ -18045,7 +19311,8 @@ "pt": "Escrever novos recursos para produtos existentes", "es": "Escribir nuevas funcionalidades para productos existentes", "tr": "Mevcut ürünlere yeni özellikler yazmak", - "uk": "Написання нових функцій для існуючих продуктів" + "uk": "Написання нових функцій для існуючих продуктів", + "ca": "Escriure noves funcions per a productes existents" }, "ONBOARDING$USE_CASE_APP_FROM_SCRATCH": { "en": "Starting an app from scratch", @@ -18061,7 +19328,8 @@ "pt": "Iniciar um aplicativo do zero", "es": "Comenzar una aplicación desde cero", "tr": "Sıfırdan bir uygulama başlatmak", - "uk": "Створення додатку з нуля" + "uk": "Створення додатку з нуля", + "ca": "Iniciar una aplicació des de zero" }, "ONBOARDING$USE_CASE_FIXING_BUGS": { "en": "Fixing bugs", @@ -18077,7 +19345,8 @@ "pt": "Corrigir bugs", "es": "Corregir errores", "tr": "Hataları düzeltmek", - "uk": "Виправлення помилок" + "uk": "Виправлення помилок", + "ca": "Corregir errors" }, "ONBOARDING$USE_CASE_REFACTORING": { "en": "Refactoring existing code / eliminating tech debt", @@ -18093,7 +19362,8 @@ "pt": "Refatorar código existente / eliminar dívida técnica", "es": "Refactorizar código existente / eliminar deuda técnica", "tr": "Mevcut kodu yeniden düzenlemek / teknik borcu ortadan kaldırmak", - "uk": "Рефакторинг існуючого коду / усунення технічного боргу" + "uk": "Рефакторинг існуючого коду / усунення технічного боргу", + "ca": "Refactoritzar el codi existent / eliminar el deute tècnic" }, "ONBOARDING$USE_CASE_AUTOMATING_TASKS": { "en": "Automating repetitive coding tasks", @@ -18109,7 +19379,8 @@ "pt": "Automatizar tarefas de codificação repetitivas", "es": "Automatizar tareas de codificación repetitivas", "tr": "Tekrarlayan kodlama görevlerini otomatikleştirmek", - "uk": "Автоматизація повторюваних завдань кодування" + "uk": "Автоматизація повторюваних завдань кодування", + "ca": "Automatitzar tasques de codificació repetitives" }, "ONBOARDING$USE_CASE_NOT_SURE": { "en": "Not sure yet", @@ -18125,7 +19396,8 @@ "pt": "Ainda não tenho certeza", "es": "Aún no estoy seguro", "tr": "Henüz emin değilim", - "uk": "Ще не впевнений" + "uk": "Ще не впевнений", + "ca": "Encara no ho sé" }, "ONBOARDING$ROLE_QUESTION": { "en": "What's your role?", @@ -18141,7 +19413,8 @@ "pt": "Qual é o seu papel?", "es": "¿Cuál es tu rol?", "tr": "Rolünüz nedir?", - "uk": "Яка ваша роль?" + "uk": "Яка ваша роль?", + "ca": "Quin és el vostre rol?" }, "ONBOARDING$ROLE_SOFTWARE_ENGINEER": { "en": "Software engineer / developer", @@ -18157,7 +19430,8 @@ "pt": "Engenheiro de software / desenvolvedor", "es": "Ingeniero de software / desarrollador", "tr": "Yazılım mühendisi / geliştirici", - "uk": "Програмний інженер / розробник" + "uk": "Програмний інженер / розробник", + "ca": "Enginyer/a de programari / desenvolupador/a" }, "ONBOARDING$ROLE_ENGINEERING_MANAGER": { "en": "Engineering manager / tech lead", @@ -18173,7 +19447,8 @@ "pt": "Gerente de engenharia / tech lead", "es": "Gerente de ingeniería / tech lead", "tr": "Mühendislik müdürü / teknik lider", - "uk": "Менеджер з розробки / технічний лідер" + "uk": "Менеджер з розробки / технічний лідер", + "ca": "Responsable d'enginyeria / líder tècnic/a" }, "ONBOARDING$ROLE_CTO_FOUNDER": { "en": "CTO / founder", @@ -18189,7 +19464,8 @@ "pt": "CTO / fundador", "es": "CTO / fundador", "tr": "CTO / kurucu", - "uk": "CTO / засновник" + "uk": "CTO / засновник", + "ca": "CTO / fundador/a" }, "ONBOARDING$ROLE_PRODUCT_OPERATIONS": { "en": "Product or operations role", @@ -18205,7 +19481,8 @@ "pt": "Função de produto ou operações", "es": "Rol de producto u operaciones", "tr": "Ürün veya operasyon rolü", - "uk": "Роль продукту або операцій" + "uk": "Роль продукту або операцій", + "ca": "Rol de producte o operacions" }, "ONBOARDING$ROLE_STUDENT_HOBBYIST": { "en": "Student / hobbyist", @@ -18221,7 +19498,8 @@ "pt": "Estudante / hobbyista", "es": "Estudiante / aficionado", "tr": "Öğrenci / hobi", - "uk": "Студент / хобіст" + "uk": "Студент / хобіст", + "ca": "Estudiant / aficionat/da" }, "ONBOARDING$ROLE_OTHER": { "en": "Other", @@ -18237,7 +19515,8 @@ "pt": "Outro", "es": "Otro", "tr": "Diğer", - "uk": "Інше" + "uk": "Інше", + "ca": "Altre" }, "ONBOARDING$NEXT_BUTTON": { "en": "Next", @@ -18253,7 +19532,8 @@ "pt": "Próximo", "es": "Siguiente", "tr": "İleri", - "uk": "Далі" + "uk": "Далі", + "ca": "Següent" }, "ONBOARDING$BACK_BUTTON": { "en": "Back", @@ -18269,7 +19549,8 @@ "pt": "Voltar", "es": "Atrás", "tr": "Geri", - "uk": "Назад" + "uk": "Назад", + "ca": "Enrere" }, "ONBOARDING$FINISH_BUTTON": { "en": "Finish", @@ -18285,7 +19566,8 @@ "pt": "Concluir", "es": "Finalizar", "tr": "Bitir", - "uk": "Завершити" + "uk": "Завершити", + "ca": "Finalitza" }, "CTA$ENTERPRISE": { "en": "Enterprise", @@ -18301,7 +19583,8 @@ "pt": "Empresarial", "es": "Empresa", "tr": "Kurumsal", - "uk": "Підприємство" + "uk": "Підприємство", + "ca": "Empresa" }, "CTA$ENTERPRISE_DEPLOY": { "en": "Deploy OpenHands on your own infrastructure. Full control over data, compliance, and security.", @@ -18317,7 +19600,8 @@ "pt": "Implante o OpenHands em sua própria infraestrutura. Controle total sobre dados, conformidade e segurança.", "es": "Implemente OpenHands en su propia infraestructura. Control total sobre datos, cumplimiento y seguridad.", "tr": "OpenHands'i kendi altyapınızda dağıtın. Veri, uyumluluk ve güvenlik üzerinde tam kontrol.", - "uk": "Розгорніть OpenHands на власній інфраструктурі. Повний контроль над даними, відповідністю та безпекою." + "uk": "Розгорніть OpenHands на власній інфраструктурі. Повний контроль над даними, відповідністю та безпекою.", + "ca": "Desplegeu OpenHands a la vostra pròpia infraestructura. Control total sobre dades, compliment i seguretat." }, "CTA$FEATURE_ON_PREMISES": { "en": "On-premises or private cloud", @@ -18333,7 +19617,8 @@ "pt": "Local ou nuvem privada", "es": "Local o nube privada", "tr": "Şirket içi veya özel bulut", - "uk": "Локально або приватна хмара" + "uk": "Локально або приватна хмара", + "ca": "Local o núvol privat" }, "CTA$FEATURE_DATA_CONTROL": { "en": "Full data control", @@ -18349,7 +19634,8 @@ "pt": "Controle total de dados", "es": "Control total de datos", "tr": "Tam veri kontrolü", - "uk": "Повний контроль даних" + "uk": "Повний контроль даних", + "ca": "Control total de les dades" }, "CTA$FEATURE_COMPLIANCE": { "en": "Custom compliance requirements", @@ -18365,7 +19651,8 @@ "pt": "Requisitos de conformidade personalizados", "es": "Requisitos de cumplimiento personalizados", "tr": "Özel uyumluluk gereksinimleri", - "uk": "Індивідуальні вимоги відповідності" + "uk": "Індивідуальні вимоги відповідності", + "ca": "Requisits de compliment personalitzats" }, "CTA$FEATURE_SUPPORT": { "en": "Dedicated support options", @@ -18381,7 +19668,8 @@ "pt": "Opções de suporte dedicado", "es": "Opciones de soporte dedicado", "tr": "Özel destek seçenekleri", - "uk": "Виділені варіанти підтримки" + "uk": "Виділені варіанти підтримки", + "ca": "Opcions de suport dedicat" }, "ENTERPRISE$SELF_HOSTED": { "en": "Self-Hosted", @@ -18397,7 +19685,8 @@ "pt": "Auto-hospedado", "es": "Autoalojado", "tr": "Kendi Sunucunuzda", - "uk": "Самостійний хостинг" + "uk": "Самостійний хостинг", + "ca": "Allotjament propi" }, "ENTERPRISE$TITLE": { "en": "OpenHands Enterprise", @@ -18413,7 +19702,8 @@ "pt": "OpenHands Enterprise", "es": "OpenHands Enterprise", "tr": "OpenHands Kurumsal", - "uk": "OpenHands Enterprise" + "uk": "OpenHands Enterprise", + "ca": "OpenHands Enterprise" }, "ENTERPRISE$DESCRIPTION": { "en": "Complete data control with your own self-hosted AI development platform.", @@ -18429,7 +19719,8 @@ "pt": "Controle completo de dados com sua própria plataforma de desenvolvimento de IA auto-hospedada.", "es": "Control completo de datos con tu propia plataforma de desarrollo de IA autoalojada.", "tr": "Kendi barındırdığınız yapay zeka geliştirme platformuyla tam veri kontrolü.", - "uk": "Повний контроль над даними з власною самостійно розміщеною платформою розробки ШІ." + "uk": "Повний контроль над даними з власною самостійно розміщеною платформою розробки ШІ.", + "ca": "Control total de les dades amb la vostra pròpia plataforma de desenvolupament d'IA allotjada." }, "ENTERPRISE$FEATURE_DATA_PRIVACY": { "en": "Full data privacy & control", @@ -18445,7 +19736,8 @@ "pt": "Privacidade e controle total de dados", "es": "Privacidad y control total de datos", "tr": "Tam veri gizliliği ve kontrolü", - "uk": "Повна конфіденційність та контроль даних" + "uk": "Повна конфіденційність та контроль даних", + "ca": "Privacitat i control total de les dades" }, "ENTERPRISE$FEATURE_DEPLOYMENT": { "en": "Custom deployment options", @@ -18461,7 +19753,8 @@ "pt": "Opções de implantação personalizadas", "es": "Opciones de despliegue personalizadas", "tr": "Özel dağıtım seçenekleri", - "uk": "Налаштовані варіанти розгортання" + "uk": "Налаштовані варіанти розгортання", + "ca": "Opcions de desplegament personalitzades" }, "ENTERPRISE$FEATURE_SSO": { "en": "SSO & enterprise auth", @@ -18477,7 +19770,8 @@ "pt": "SSO e autenticação empresarial", "es": "SSO y autenticación empresarial", "tr": "SSO ve kurumsal kimlik doğrulama", - "uk": "SSO та корпоративна автентифікація" + "uk": "SSO та корпоративна автентифікація", + "ca": "SSO i autenticació empresarial" }, "ENTERPRISE$FEATURE_SUPPORT": { "en": "Dedicated support", @@ -18493,7 +19787,8 @@ "pt": "Suporte dedicado", "es": "Soporte dedicado", "tr": "Özel destek", - "uk": "Виділена підтримка" + "uk": "Виділена підтримка", + "ca": "Suport dedicat" }, "ENTERPRISE$LEARN_MORE": { "en": "Learn More", @@ -18510,6 +19805,8 @@ "es": "Más información", "tr": "Daha Fazla Bilgi", "uk": "Дізнатися більше" + , + "ca": "Més informació" }, "ENTERPRISE$LEARN_MORE_ARIA": { "en": "Learn more about OpenHands Enterprise (opens in new window)", @@ -18525,7 +19822,8 @@ "pt": "Saiba mais sobre OpenHands Enterprise (abre em nova janela)", "es": "Más información sobre OpenHands Enterprise (abre en nueva ventana)", "tr": "OpenHands Enterprise hakkında daha fazla bilgi edinin (yeni pencerede açılır)", - "uk": "Дізнатися більше про OpenHands Enterprise (відкривається в новому вікні)" + "uk": "Дізнатися більше про OpenHands Enterprise (відкривається в новому вікні)", + "ca": "Més informació sobre OpenHands Enterprise (s'obre en una finestra nova)" }, "DEVICE$SUCCESS_TITLE": { "en": "Success!", @@ -18541,7 +19839,8 @@ "pt": "Sucesso!", "es": "¡Éxito!", "tr": "Başarılı!", - "uk": "Успіх!" + "uk": "Успіх!", + "ca": "Correcte!" }, "DEVICE$ERROR_TITLE": { "en": "Error", @@ -18558,6 +19857,8 @@ "es": "Error", "tr": "Hata", "uk": "Помилка" + , + "ca": "Error" }, "DEVICE$SUCCESS_MESSAGE": { "en": "Device authorized successfully! You can now return to your CLI and close this window.", @@ -18573,7 +19874,8 @@ "pt": "Dispositivo autorizado com sucesso! Você pode voltar ao CLI e fechar esta janela.", "es": "¡Dispositivo autorizado exitosamente! Ahora puedes volver a tu CLI y cerrar esta ventana.", "tr": "Cihaz başarıyla yetkilendirildi! Artık CLI'nize dönebilir ve bu pencereyi kapatabilirsiniz.", - "uk": "Пристрій успішно авторизовано! Тепер ви можете повернутися до CLI та закрити це вікно." + "uk": "Пристрій успішно авторизовано! Тепер ви можете повернутися до CLI та закрити це вікно.", + "ca": "Dispositiu autoritzat correctament. Ja podeu tornar a la vostra CLI i tancar aquesta finestra." }, "DEVICE$ERROR_FAILED": { "en": "Failed to authorize device. Please try again.", @@ -18589,7 +19891,8 @@ "pt": "Falha ao autorizar o dispositivo. Por favor, tente novamente.", "es": "Error al autorizar el dispositivo. Por favor, inténtalo de nuevo.", "tr": "Cihaz yetkilendirilemedi. Lütfen tekrar deneyin.", - "uk": "Не вдалося авторизувати пристрій. Будь ласка, спробуйте ще раз." + "uk": "Не вдалося авторизувати пристрій. Будь ласка, спробуйте ще раз.", + "ca": "No s'ha pogut autoritzar el dispositiu. Torneu-ho a intentar." }, "DEVICE$ERROR_OCCURRED": { "en": "An error occurred while authorizing the device. Please try again.", @@ -18605,7 +19908,8 @@ "pt": "Ocorreu um erro ao autorizar o dispositivo. Por favor, tente novamente.", "es": "Ocurrió un error al autorizar el dispositivo. Por favor, inténtalo de nuevo.", "tr": "Cihaz yetkilendirirken bir hata oluştu. Lütfen tekrar deneyin.", - "uk": "Під час авторизації пристрою сталася помилка. Будь ласка, спробуйте ще раз." + "uk": "Під час авторизації пристрою сталася помилка. Будь ласка, спробуйте ще раз.", + "ca": "S'ha produït un error en autoritzar el dispositiu. Torneu-ho a intentar." }, "DEVICE$TRY_AGAIN": { "en": "Try Again", @@ -18621,7 +19925,8 @@ "pt": "Tentar novamente", "es": "Intentar de nuevo", "tr": "Tekrar Dene", - "uk": "Спробувати ще раз" + "uk": "Спробувати ще раз", + "ca": "Torna-ho a intentar" }, "DEVICE$PROCESSING": { "en": "Processing device verification...", @@ -18637,7 +19942,8 @@ "pt": "Processando verificação do dispositivo...", "es": "Procesando verificación del dispositivo...", "tr": "Cihaz doğrulaması işleniyor...", - "uk": "Обробка перевірки пристрою..." + "uk": "Обробка перевірки пристрою...", + "ca": "S'està processant la verificació del dispositiu..." }, "DEVICE$AUTHORIZATION_REQUEST": { "en": "Device Authorization Request", @@ -18653,7 +19959,8 @@ "pt": "Solicitação de autorização do dispositivo", "es": "Solicitud de autorización del dispositivo", "tr": "Cihaz Yetkilendirme Talebi", - "uk": "Запит на авторизацію пристрою" + "uk": "Запит на авторизацію пристрою", + "ca": "Sol·licitud d'autorització del dispositiu" }, "DEVICE$CODE_LABEL": { "en": "DEVICE CODE", @@ -18669,7 +19976,8 @@ "pt": "CÓDIGO DO DISPOSITIVO", "es": "CÓDIGO DE DISPOSITIVO", "tr": "CİHAZ KODU", - "uk": "КОД ПРИСТРОЮ" + "uk": "КОД ПРИСТРОЮ", + "ca": "CODI DEL DISPOSITIU" }, "DEVICE$SECURITY_NOTICE": { "en": "Security Notice", @@ -18685,7 +19993,8 @@ "pt": "Aviso de segurança", "es": "Aviso de seguridad", "tr": "Güvenlik Bildirimi", - "uk": "Повідомлення про безпеку" + "uk": "Повідомлення про безпеку", + "ca": "Avís de seguretat" }, "DEVICE$SECURITY_WARNING": { "en": "Only authorize this device if you initiated this request from your CLI or application.", @@ -18701,7 +20010,8 @@ "pt": "Autorize este dispositivo apenas se você iniciou esta solicitação do seu CLI ou aplicativo.", "es": "Solo autoriza este dispositivo si iniciaste esta solicitud desde tu CLI o aplicación.", "tr": "Bu cihazı yalnızca bu isteği CLI veya uygulamanızdan başlattıysanız yetkilendirin.", - "uk": "Авторизуйте цей пристрій лише якщо ви ініціювали цей запит з вашого CLI або додатку." + "uk": "Авторизуйте цей пристрій лише якщо ви ініціювали цей запит з вашого CLI або додатку.", + "ca": "Només autoritzi aquest dispositiu si vau iniciar aquesta sol·licitud des de la vostra CLI o aplicació." }, "DEVICE$CONFIRM_PROMPT": { "en": "Do you want to authorize this device to access your OpenHands account?", @@ -18717,7 +20027,8 @@ "pt": "Deseja autorizar este dispositivo a acessar sua conta OpenHands?", "es": "¿Deseas autorizar este dispositivo para acceder a tu cuenta de OpenHands?", "tr": "Bu cihazın OpenHands hesabınıza erişmesine izin vermek istiyor musunuz?", - "uk": "Бажаєте авторизувати цей пристрій для доступу до вашого облікового запису OpenHands?" + "uk": "Бажаєте авторизувати цей пристрій для доступу до вашого облікового запису OpenHands?", + "ca": "Voleu autoritzar aquest dispositiu per accedir al vostre compte d'OpenHands?" }, "DEVICE$CANCEL": { "en": "Cancel", @@ -18733,7 +20044,8 @@ "pt": "Cancelar", "es": "Cancelar", "tr": "İptal", - "uk": "Скасувати" + "uk": "Скасувати", + "ca": "Cancel·la" }, "DEVICE$AUTHORIZE": { "en": "Authorize Device", @@ -18749,7 +20061,8 @@ "pt": "Autorizar dispositivo", "es": "Autorizar dispositivo", "tr": "Cihazı Yetkilendir", - "uk": "Авторизувати пристрій" + "uk": "Авторизувати пристрій", + "ca": "Autoritza el dispositiu" }, "DEVICE$AUTHORIZATION_TITLE": { "en": "Device Authorization", @@ -18765,7 +20078,8 @@ "pt": "Autorização do dispositivo", "es": "Autorización del dispositivo", "tr": "Cihaz Yetkilendirme", - "uk": "Авторизація пристрою" + "uk": "Авторизація пристрою", + "ca": "Autorització del dispositiu" }, "DEVICE$ENTER_CODE_PROMPT": { "en": "Enter the code displayed on your device:", @@ -18781,7 +20095,8 @@ "pt": "Digite o código exibido no seu dispositivo:", "es": "Ingresa el código mostrado en tu dispositivo:", "tr": "Cihazınızda görüntülenen kodu girin:", - "uk": "Введіть код, відображений на вашому пристрої:" + "uk": "Введіть код, відображений на вашому пристрої:", + "ca": "Introduïu el codi que es mostra al vostre dispositiu:" }, "DEVICE$CODE_INPUT_LABEL": { "en": "Device Code:", @@ -18797,7 +20112,8 @@ "pt": "Código do dispositivo:", "es": "Código del dispositivo:", "tr": "Cihaz Kodu:", - "uk": "Код пристрою:" + "uk": "Код пристрою:", + "ca": "Codi del dispositiu:" }, "DEVICE$CODE_PLACEHOLDER": { "en": "Enter your device code", @@ -18813,7 +20129,8 @@ "pt": "Digite o código do seu dispositivo", "es": "Ingresa tu código de dispositivo", "tr": "Cihaz kodunuzu girin", - "uk": "Введіть код вашого пристрою" + "uk": "Введіть код вашого пристрою", + "ca": "Introduïu el codi del dispositiu" }, "DEVICE$CONTINUE": { "en": "Continue", @@ -18829,7 +20146,8 @@ "pt": "Continuar", "es": "Continuar", "tr": "Devam", - "uk": "Продовжити" + "uk": "Продовжити", + "ca": "Continua" }, "DEVICE$AUTH_REQUIRED": { "en": "Authentication Required", @@ -18845,7 +20163,8 @@ "pt": "Autenticação necessária", "es": "Autenticación requerida", "tr": "Kimlik Doğrulama Gerekli", - "uk": "Потрібна автентифікація" + "uk": "Потрібна автентифікація", + "ca": "Cal autenticació" }, "DEVICE$SIGN_IN_PROMPT": { "en": "Please sign in to authorize your device.", @@ -18861,7 +20180,8 @@ "pt": "Por favor, faça login para autorizar seu dispositivo.", "es": "Por favor, inicia sesión para autorizar tu dispositivo.", "tr": "Cihazınızı yetkilendirmek için lütfen giriş yapın.", - "uk": "Будь ласка, увійдіть, щоб авторизувати свій пристрій." + "uk": "Будь ласка, увійдіть, щоб авторизувати свій пристрій.", + "ca": "Inicieu sessió per autoritzar el vostre dispositiu." }, "CTA$ENTERPRISE_TITLE": { "en": "Get OpenHands For Enterprise", @@ -18877,7 +20197,8 @@ "fr": "Obtenez OpenHands pour Entreprise", "tr": "Kurumsal OpenHands'i Edinin", "de": "OpenHands für Unternehmen", - "uk": "Отримайте OpenHands для підприємств" + "uk": "Отримайте OpenHands для підприємств", + "ca": "Obteniu OpenHands per a empreses" }, "CTA$ENTERPRISE_DESCRIPTION": { "en": "Cloud allows you to access OpenHands anywhere and coordinate with your team like never before.", @@ -18893,7 +20214,8 @@ "fr": "Cloud vous permet d'accéder à OpenHands n'importe où et de coordonner avec votre équipe comme jamais auparavant.", "tr": "Cloud, OpenHands'e her yerden erişmenizi ve ekibinizle daha önce hiç olmadığı gibi koordinasyon sağlamanızı mümkün kılar.", "de": "Cloud ermöglicht Ihnen den Zugriff auf OpenHands von überall und die Koordination mit Ihrem Team wie nie zuvor.", - "uk": "Cloud дозволяє отримати доступ до OpenHands будь-де та координувати роботу з вашою командою як ніколи раніше." + "uk": "Cloud дозволяє отримати доступ до OpenHands будь-де та координувати роботу з вашою командою як ніколи раніше.", + "ca": "El núvol us permet accedir a OpenHands des de qualsevol lloc i coordinar-vos amb el vostre equip com mai abans." }, "CTA$LEARN_MORE": { "en": "Learn more", @@ -18910,5 +20232,7 @@ "tr": "Daha fazla bilgi", "de": "Mehr erfahren", "uk": "Дізнатися більше" + , + "ca": "Més informació" } } From 6d86803f4154894db19230d66c83e2d18fd3f9dd Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:26:27 -0700 Subject: [PATCH 65/92] Add loading feedback to git changes refresh button (#12792) Co-authored-by: hieptl --- frontend/__tests__/routes/changes-tab.test.tsx | 2 ++ .../conversation-tabs/conversation-tab-title.tsx | 12 +++++++++--- .../src/hooks/query/use-unified-get-git-changes.ts | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/__tests__/routes/changes-tab.test.tsx b/frontend/__tests__/routes/changes-tab.test.tsx index 178bb28c40..1cf2513d18 100644 --- a/frontend/__tests__/routes/changes-tab.test.tsx +++ b/frontend/__tests__/routes/changes-tab.test.tsx @@ -32,6 +32,7 @@ describe("Changes Tab", () => { vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ data: [], isLoading: false, + isFetching: false, isSuccess: true, isError: false, error: null, @@ -50,6 +51,7 @@ describe("Changes Tab", () => { vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ data: [{ path: "src/file.ts", status: "M" }], isLoading: false, + isFetching: false, isSuccess: true, isError: false, error: null, diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-title.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-title.tsx index 75dbb23f8e..ad3bc98c41 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-title.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-title.tsx @@ -20,7 +20,7 @@ export function ConversationTabTitle({ conversationKey, }: ConversationTabTitleProps) { const { t } = useTranslation(); - const { refetch } = useUnifiedGetGitChanges(); + const { refetch, isFetching } = useUnifiedGetGitChanges(); const { handleBuildPlanClick } = useHandleBuildPlanClick(); const { curAgentState } = useAgentState(); const { planContent } = useConversationStore(); @@ -41,10 +41,16 @@ export function ConversationTabTitle({ {conversationKey === "editor" && ( )} {conversationKey === "planner" && ( diff --git a/frontend/src/hooks/query/use-unified-get-git-changes.ts b/frontend/src/hooks/query/use-unified-get-git-changes.ts index 70bc5f451f..801b1a067a 100644 --- a/frontend/src/hooks/query/use-unified-get-git-changes.ts +++ b/frontend/src/hooks/query/use-unified-get-git-changes.ts @@ -100,6 +100,7 @@ export const useUnifiedGetGitChanges = () => { return { data: orderedChanges, isLoading: result.isLoading, + isFetching: result.isFetching, isSuccess: result.isSuccess, isError: result.isError, error: result.error, From 39a4ca422f35b276d8b508bf52e9fddd9ae5a0d5 Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Wed, 18 Mar 2026 11:42:46 -0700 Subject: [PATCH 66/92] fix: use sentence case for 'Waiting for sandbox' text (#12958) Co-authored-by: openhands --- frontend/__tests__/utils/utils.test.ts | 4 ++-- frontend/src/utils/utils.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/__tests__/utils/utils.test.ts b/frontend/__tests__/utils/utils.test.ts index 91e9ba031b..8fc286d4b1 100644 --- a/frontend/__tests__/utils/utils.test.ts +++ b/frontend/__tests__/utils/utils.test.ts @@ -11,7 +11,7 @@ import { I18nKey } from "#/i18n/declaration"; // Mock translations const t = (key: string) => { const translations: { [key: string]: string } = { - COMMON$WAITING_FOR_SANDBOX: "Waiting For Sandbox", + COMMON$WAITING_FOR_SANDBOX: "Waiting for sandbox", COMMON$STOPPING: "Stopping", COMMON$STARTING: "Starting", COMMON$SERVER_STOPPED: "Server stopped", @@ -69,7 +69,7 @@ describe("getStatusText", () => { t, }); - expect(result).toBe(t(I18nKey.COMMON$WAITING_FOR_SANDBOX)); + expect(result).toBe("Waiting for sandbox"); }); it("returns task detail when task status is ERROR and detail exists", () => { diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 849d65fbdf..80f40158f6 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -838,7 +838,7 @@ interface GetStatusTextArgs { * isStartingStatus: false, * isStopStatus: false, * curAgentState: AgentState.RUNNING - * }) // Returns "Waiting For Sandbox" + * }) // Returns "Waiting for sandbox" */ export function getStatusText({ isPausing = false, @@ -866,13 +866,13 @@ export function getStatusText({ return t(I18nKey.CONVERSATION$READY); } - // Format status text: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox" + // Format status text with sentence case: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox" return ( taskDetail || taskStatus .toLowerCase() .replace(/_/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()) + .replace(/^\w/, (c) => c.toUpperCase()) ); } From db41148396188a7566f412f1053211a655aaea7d Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:46:23 +0700 Subject: [PATCH 67/92] feat(backend): expose API key org_id via new GET /api/keys/current endpoint (org project) (#13469) --- enterprise/server/auth/saas_user_auth.py | 18 +++- enterprise/server/routes/api_keys.py | 57 ++++++++++++- enterprise/storage/api_key_store.py | 22 ++++- .../storage/saas_conversation_validator.py | 6 +- .../tests/unit/server/routes/test_api_keys.py | 85 +++++++++++++++++++ enterprise/tests/unit/test_api_key_store.py | 14 ++- enterprise/tests/unit/test_saas_user_auth.py | 17 +++- 7 files changed, 203 insertions(+), 16 deletions(-) diff --git a/enterprise/server/auth/saas_user_auth.py b/enterprise/server/auth/saas_user_auth.py index c2b3e1fbe9..6c8aefea7a 100644 --- a/enterprise/server/auth/saas_user_auth.py +++ b/enterprise/server/auth/saas_user_auth.py @@ -1,6 +1,7 @@ import time from dataclasses import dataclass from types import MappingProxyType +from uuid import UUID import jwt from fastapi import Request @@ -59,6 +60,10 @@ class SaasUserAuth(UserAuth): _secrets: Secrets | None = None accepted_tos: bool | None = None auth_type: AuthType = AuthType.COOKIE + # API key context fields - populated when authenticated via API key + api_key_org_id: UUID | None = None + api_key_id: int | None = None + api_key_name: str | None = None async def get_user_id(self) -> str | None: return self.user_id @@ -283,14 +288,19 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None: return None api_key_store = ApiKeyStore.get_instance() - user_id = await api_key_store.validate_api_key(api_key) - if not user_id: + validation_result = await api_key_store.validate_api_key(api_key) + if not validation_result: return None - offline_token = await token_manager.load_offline_token(user_id) + offline_token = await token_manager.load_offline_token( + validation_result.user_id + ) saas_user_auth = SaasUserAuth( - user_id=user_id, + user_id=validation_result.user_id, refresh_token=SecretStr(offline_token), auth_type=AuthType.BEARER, + api_key_org_id=validation_result.org_id, + api_key_id=validation_result.key_id, + api_key_name=validation_result.key_name, ) await saas_user_auth.refresh() return saas_user_auth diff --git a/enterprise/server/routes/api_keys.py b/enterprise/server/routes/api_keys.py index d5f30f87cf..31320966da 100644 --- a/enterprise/server/routes/api_keys.py +++ b/enterprise/server/routes/api_keys.py @@ -1,7 +1,9 @@ from datetime import UTC, datetime +from typing import cast -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, field_validator +from server.auth.saas_user_auth import SaasUserAuth from storage.api_key import ApiKey from storage.api_key_store import ApiKeyStore from storage.lite_llm_manager import LiteLlmManager @@ -11,7 +13,8 @@ from storage.org_service import OrgService from storage.user_store import UserStore from openhands.core.logger import openhands_logger as logger -from openhands.server.user_auth import get_user_id +from openhands.server.user_auth import get_user_auth, get_user_id +from openhands.server.user_auth.user_auth import AuthType # Helper functions for BYOR API key management @@ -150,6 +153,16 @@ class MessageResponse(BaseModel): message: str +class CurrentApiKeyResponse(BaseModel): + """Response model for the current API key endpoint.""" + + id: int + name: str | None + org_id: str + user_id: str + auth_type: str + + def api_key_to_response(key: ApiKey) -> ApiKeyResponse: """Convert an ApiKey model to an ApiKeyResponse.""" return ApiKeyResponse( @@ -262,6 +275,46 @@ async def delete_api_key( ) +@api_router.get('/current', tags=['Keys']) +async def get_current_api_key( + request: Request, + user_id: str = Depends(get_user_id), +) -> CurrentApiKeyResponse: + """Get information about the currently authenticated API key. + + This endpoint returns metadata about the API key used for the current request, + including the org_id associated with the key. This is useful for API key + callers who need to know which organization context their key operates in. + + Returns 400 if not authenticated via API key (e.g., using cookie auth). + """ + user_auth = await get_user_auth(request) + + # Check if authenticated via API key + if user_auth.get_auth_type() != AuthType.BEARER: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='This endpoint requires API key authentication. Not available for cookie-based auth.', + ) + + # In SaaS context, bearer auth always produces SaasUserAuth + saas_user_auth = cast(SaasUserAuth, user_auth) + + if saas_user_auth.api_key_org_id is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='This API key was created before organization support. Please regenerate your API key to use this endpoint.', + ) + + return CurrentApiKeyResponse( + id=saas_user_auth.api_key_id, + name=saas_user_auth.api_key_name, + org_id=str(saas_user_auth.api_key_org_id), + user_id=user_id, + auth_type=saas_user_auth.auth_type.value, + ) + + @api_router.get('/llm/byor', tags=['Keys']) async def get_llm_api_key_for_byor( user_id: str = Depends(get_user_id), diff --git a/enterprise/storage/api_key_store.py b/enterprise/storage/api_key_store.py index 74a2d3d73e..3090b8da07 100644 --- a/enterprise/storage/api_key_store.py +++ b/enterprise/storage/api_key_store.py @@ -4,6 +4,7 @@ import secrets import string from dataclasses import dataclass from datetime import UTC, datetime +from uuid import UUID from sqlalchemy import select, update from storage.api_key import ApiKey @@ -13,6 +14,16 @@ from storage.user_store import UserStore from openhands.core.logger import openhands_logger as logger +@dataclass +class ApiKeyValidationResult: + """Result of API key validation containing user and org context.""" + + user_id: str + org_id: UUID | None + key_id: int + key_name: str | None + + @dataclass class ApiKeyStore: API_KEY_PREFIX = 'sk-oh-' @@ -60,8 +71,8 @@ class ApiKeyStore: return api_key - async def validate_api_key(self, api_key: str) -> str | None: - """Validate an API key and return the associated user_id if valid.""" + async def validate_api_key(self, api_key: str) -> ApiKeyValidationResult | None: + """Validate an API key and return the associated user_id and org_id if valid.""" now = datetime.now(UTC) async with a_session_maker() as session: @@ -89,7 +100,12 @@ class ApiKeyStore: ) await session.commit() - return key_record.user_id + return ApiKeyValidationResult( + user_id=key_record.user_id, + org_id=key_record.org_id, + key_id=key_record.id, + key_name=key_record.name, + ) async def delete_api_key(self, api_key: str) -> bool: """Delete an API key by the key value.""" diff --git a/enterprise/storage/saas_conversation_validator.py b/enterprise/storage/saas_conversation_validator.py index bff4468011..51a5302dfc 100644 --- a/enterprise/storage/saas_conversation_validator.py +++ b/enterprise/storage/saas_conversation_validator.py @@ -28,12 +28,14 @@ class SaasConversationValidator(ConversationValidator): # Validate the API key and get the user_id api_key_store = ApiKeyStore.get_instance() - user_id = await api_key_store.validate_api_key(api_key) + validation_result = await api_key_store.validate_api_key(api_key) - if not user_id: + if not validation_result: logger.warning('Invalid API key') return None + user_id = validation_result.user_id + # Get the offline token for the user offline_token = await token_manager.load_offline_token(user_id) if not offline_token: diff --git a/enterprise/tests/unit/server/routes/test_api_keys.py b/enterprise/tests/unit/server/routes/test_api_keys.py index 57a9cb465d..4c35e9d5be 100644 --- a/enterprise/tests/unit/server/routes/test_api_keys.py +++ b/enterprise/tests/unit/server/routes/test_api_keys.py @@ -1,19 +1,26 @@ """Unit tests for API keys routes, focusing on BYOR key validation and retrieval.""" +import uuid from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from fastapi import HTTPException +from pydantic import SecretStr +from server.auth.saas_user_auth import SaasUserAuth from server.routes.api_keys import ( ByorPermittedResponse, + CurrentApiKeyResponse, LlmApiKeyResponse, check_byor_permitted, delete_byor_key_from_litellm, + get_current_api_key, get_llm_api_key_for_byor, ) from storage.lite_llm_manager import LiteLlmManager +from openhands.server.user_auth.user_auth import AuthType + class TestVerifyByorKeyInLitellm: """Test the verify_byor_key_in_litellm function.""" @@ -512,3 +519,81 @@ class TestCheckByorPermitted: assert exc_info.value.status_code == 500 assert 'Failed to check BYOR export permission' in exc_info.value.detail + + +class TestGetCurrentApiKey: + """Test the get_current_api_key endpoint.""" + + @pytest.mark.asyncio + @patch('server.routes.api_keys.get_user_auth') + async def test_returns_api_key_info_for_bearer_auth(self, mock_get_user_auth): + """Test that API key metadata including org_id is returned for bearer token auth.""" + # Arrange + user_id = 'user-123' + org_id = uuid.uuid4() + mock_request = MagicMock() + + user_auth = SaasUserAuth( + refresh_token=SecretStr('mock-token'), + user_id=user_id, + auth_type=AuthType.BEARER, + api_key_org_id=org_id, + api_key_id=42, + api_key_name='My Production Key', + ) + mock_get_user_auth.return_value = user_auth + + # Act + result = await get_current_api_key(request=mock_request, user_id=user_id) + + # Assert + assert isinstance(result, CurrentApiKeyResponse) + assert result.org_id == str(org_id) + assert result.id == 42 + assert result.name == 'My Production Key' + assert result.user_id == user_id + assert result.auth_type == 'bearer' + + @pytest.mark.asyncio + @patch('server.routes.api_keys.get_user_auth') + async def test_returns_400_for_cookie_auth(self, mock_get_user_auth): + """Test that 400 Bad Request is returned when using cookie authentication.""" + # Arrange + user_id = 'user-123' + mock_request = MagicMock() + + mock_user_auth = MagicMock() + mock_user_auth.get_auth_type.return_value = AuthType.COOKIE + mock_get_user_auth.return_value = mock_user_auth + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await get_current_api_key(request=mock_request, user_id=user_id) + + assert exc_info.value.status_code == 400 + assert 'API key authentication' in exc_info.value.detail + + @pytest.mark.asyncio + @patch('server.routes.api_keys.get_user_auth') + async def test_returns_400_when_api_key_org_id_is_none(self, mock_get_user_auth): + """Test that 400 is returned when API key has no org_id (legacy key).""" + # Arrange + user_id = 'user-123' + mock_request = MagicMock() + + user_auth = SaasUserAuth( + refresh_token=SecretStr('mock-token'), + user_id=user_id, + auth_type=AuthType.BEARER, + api_key_org_id=None, # No org_id - legacy key + api_key_id=42, + api_key_name='Legacy Key', + ) + mock_get_user_auth.return_value = user_auth + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await get_current_api_key(request=mock_request, user_id=user_id) + + assert exc_info.value.status_code == 400 + assert 'created before organization support' in exc_info.value.detail diff --git a/enterprise/tests/unit/test_api_key_store.py b/enterprise/tests/unit/test_api_key_store.py index d3a2d13d1e..baffe5893c 100644 --- a/enterprise/tests/unit/test_api_key_store.py +++ b/enterprise/tests/unit/test_api_key_store.py @@ -126,13 +126,18 @@ async def test_validate_api_key_valid(api_key_store, async_session_maker): ) session.add(key_record) await session.commit() + key_id = key_record.id # Execute - patch a_session_maker to use test's async session maker with patch('storage.api_key_store.a_session_maker', async_session_maker): result = await api_key_store.validate_api_key(api_key_value) - # Verify - assert result == user_id + # Verify - result is now ApiKeyValidationResult + assert result is not None + assert result.user_id == user_id + assert result.org_id == org_id + assert result.key_id == key_id + assert result.key_name == 'Test Key' @pytest.mark.asyncio @@ -218,8 +223,9 @@ async def test_validate_api_key_valid_timezone_naive( with patch('storage.api_key_store.a_session_maker', async_session_maker): result = await api_key_store.validate_api_key(api_key_value) - # Verify - assert result == user_id + # Verify - result is now ApiKeyValidationResult + assert result is not None + assert result.user_id == user_id @pytest.mark.asyncio diff --git a/enterprise/tests/unit/test_saas_user_auth.py b/enterprise/tests/unit/test_saas_user_auth.py index 92552de3ad..2fb1b68445 100644 --- a/enterprise/tests/unit/test_saas_user_auth.py +++ b/enterprise/tests/unit/test_saas_user_auth.py @@ -1,4 +1,5 @@ import time +import uuid from unittest.mock import AsyncMock, MagicMock, patch import jwt @@ -18,6 +19,7 @@ from server.auth.saas_user_auth import ( saas_user_auth_from_cookie, saas_user_auth_from_signed_token, ) +from storage.api_key_store import ApiKeyValidationResult from storage.user_authorization import UserAuthorizationType from openhands.integrations.provider import ProviderToken, ProviderType @@ -468,12 +470,22 @@ async def test_saas_user_auth_from_bearer_success(): algorithm='HS256', ) + mock_org_id = uuid.uuid4() + mock_validation_result = ApiKeyValidationResult( + user_id='test_user_id', + org_id=mock_org_id, + key_id=42, + key_name='Test Key', + ) + with ( patch('server.auth.saas_user_auth.ApiKeyStore') as mock_api_key_store_cls, patch('server.auth.saas_user_auth.token_manager') as mock_token_manager, ): mock_api_key_store = MagicMock() - mock_api_key_store.validate_api_key = AsyncMock(return_value='test_user_id') + mock_api_key_store.validate_api_key = AsyncMock( + return_value=mock_validation_result + ) mock_api_key_store_cls.get_instance.return_value = mock_api_key_store mock_token_manager.load_offline_token = AsyncMock(return_value=offline_token) @@ -485,6 +497,9 @@ async def test_saas_user_auth_from_bearer_success(): assert isinstance(result, SaasUserAuth) assert result.user_id == 'test_user_id' + assert result.api_key_org_id == mock_org_id + assert result.api_key_id == 42 + assert result.api_key_name == 'Test Key' mock_api_key_store.validate_api_key.assert_called_once_with('test_api_key') mock_token_manager.load_offline_token.assert_called_once_with('test_user_id') mock_token_manager.refresh.assert_called_once_with(offline_token) From 1d1ffc2be0454db99cfc77dc05cba5da5e74688c Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Wed, 18 Mar 2026 15:07:36 -0400 Subject: [PATCH 68/92] feat(enterprise): Add service API for automation API key creation (#13467) Co-authored-by: openhands --- enterprise/saas_server.py | 2 + enterprise/server/middleware.py | 4 + enterprise/server/routes/service.py | 270 ++++++++++++++ enterprise/storage/api_key_store.py | 199 ++++++++++- enterprise/tests/unit/routes/__init__.py | 0 enterprise/tests/unit/routes/test_service.py | 331 ++++++++++++++++++ .../tests/unit/storage/test_api_key_store.py | 314 +++++++++++++++++ 7 files changed, 1110 insertions(+), 10 deletions(-) create mode 100644 enterprise/server/routes/service.py create mode 100644 enterprise/tests/unit/routes/__init__.py create mode 100644 enterprise/tests/unit/routes/test_service.py create mode 100644 enterprise/tests/unit/storage/test_api_key_store.py diff --git a/enterprise/saas_server.py b/enterprise/saas_server.py index 8bb576a55b..434652befd 100644 --- a/enterprise/saas_server.py +++ b/enterprise/saas_server.py @@ -46,6 +46,7 @@ from server.routes.org_invitations import ( # noqa: E402 ) from server.routes.orgs import org_router # noqa: E402 from server.routes.readiness import readiness_router # noqa: E402 +from server.routes.service import service_router # noqa: E402 from server.routes.user import saas_user_router # noqa: E402 from server.routes.user_app_settings import user_app_settings_router # noqa: E402 from server.sharing.shared_conversation_router import ( # noqa: E402 @@ -112,6 +113,7 @@ if GITLAB_APP_CLIENT_ID: base_app.include_router(gitlab_integration_router) base_app.include_router(api_keys_router) # Add routes for API key management +base_app.include_router(service_router) # Add routes for internal service API base_app.include_router(org_router) # Add routes for organization management base_app.include_router( verified_models_router diff --git a/enterprise/server/middleware.py b/enterprise/server/middleware.py index 659a66046a..c014864b0b 100644 --- a/enterprise/server/middleware.py +++ b/enterprise/server/middleware.py @@ -182,6 +182,10 @@ class SetAuthCookieMiddleware: if path.startswith('/api/v1/webhooks/'): return False + # Service API uses its own authentication (X-Service-API-Key header) + if path.startswith('/api/service/'): + return False + is_mcp = path.startswith('/mcp') is_api_route = path.startswith('/api') return is_api_route or is_mcp diff --git a/enterprise/server/routes/service.py b/enterprise/server/routes/service.py new file mode 100644 index 0000000000..87e470dd7c --- /dev/null +++ b/enterprise/server/routes/service.py @@ -0,0 +1,270 @@ +""" +Service API routes for internal service-to-service communication. + +This module provides endpoints for trusted internal services (e.g., automations service) +to perform privileged operations like creating API keys on behalf of users. + +Authentication is via a shared secret (X-Service-API-Key header) configured +through the AUTOMATIONS_SERVICE_API_KEY environment variable. +""" + +import os +from uuid import UUID + +from fastapi import APIRouter, Header, HTTPException, status +from pydantic import BaseModel, field_validator +from storage.api_key_store import ApiKeyStore +from storage.org_member_store import OrgMemberStore +from storage.user_store import UserStore + +from openhands.core.logger import openhands_logger as logger + +# Environment variable for the service API key +AUTOMATIONS_SERVICE_API_KEY = os.getenv('AUTOMATIONS_SERVICE_API_KEY', '').strip() + +service_router = APIRouter(prefix='/api/service', tags=['Service']) + + +class CreateUserApiKeyRequest(BaseModel): + """Request model for creating an API key on behalf of a user.""" + + name: str # Required - used to identify the key + + @field_validator('name') + @classmethod + def validate_name(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError('name is required and cannot be empty') + return v.strip() + + +class CreateUserApiKeyResponse(BaseModel): + """Response model for created API key.""" + + key: str + user_id: str + org_id: str + name: str + + +class ServiceInfoResponse(BaseModel): + """Response model for service info endpoint.""" + + service: str + authenticated: bool + + +async def validate_service_api_key( + x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'), +) -> str: + """ + Validate the service API key from the request header. + + Args: + x_service_api_key: The service API key from the X-Service-API-Key header + + Returns: + str: Service identifier for audit logging + + Raises: + HTTPException: 401 if key is missing or invalid + HTTPException: 503 if service auth is not configured + """ + if not AUTOMATIONS_SERVICE_API_KEY: + logger.warning( + 'Service authentication not configured (AUTOMATIONS_SERVICE_API_KEY not set)' + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail='Service authentication not configured', + ) + + if not x_service_api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='X-Service-API-Key header is required', + ) + + if x_service_api_key != AUTOMATIONS_SERVICE_API_KEY: + logger.warning('Invalid service API key attempted') + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Invalid service API key', + ) + + return 'automations-service' + + +@service_router.get('/health') +async def service_health() -> dict: + """Health check endpoint for the service API. + + This endpoint does not require authentication and can be used + to verify the service routes are accessible. + """ + return { + 'status': 'ok', + 'service_auth_configured': bool(AUTOMATIONS_SERVICE_API_KEY), + } + + +@service_router.post('/users/{user_id}/orgs/{org_id}/api-keys') +async def get_or_create_api_key_for_user( + user_id: str, + org_id: UUID, + request: CreateUserApiKeyRequest, + x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'), +) -> CreateUserApiKeyResponse: + """ + Get or create an API key for a user on behalf of the automations service. + + If a key with the given name already exists for the user/org and is not expired, + returns the existing key. Otherwise, creates a new key. + + The created/returned keys are system keys and are: + - Not visible to the user in their API keys list + - Not deletable by the user + - Never expire + + Args: + user_id: The user ID + org_id: The organization ID + request: Request body containing name (required) + x_service_api_key: Service API key header for authentication + + Returns: + CreateUserApiKeyResponse: The API key and metadata + + Raises: + HTTPException: 401 if service key is invalid + HTTPException: 404 if user not found + HTTPException: 403 if user is not a member of the specified org + """ + # Validate service API key + service_id = await validate_service_api_key(x_service_api_key) + + # Verify user exists + user = await UserStore.get_user_by_id(user_id) + if not user: + logger.warning( + 'Service attempted to create key for non-existent user', + extra={'user_id': user_id}, + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f'User {user_id} not found', + ) + + # Verify user is a member of the specified org + org_member = await OrgMemberStore.get_org_member(org_id, UUID(user_id)) + if not org_member: + logger.warning( + 'Service attempted to create key for user not in org', + extra={ + 'user_id': user_id, + 'org_id': str(org_id), + }, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f'User {user_id} is not a member of org {org_id}', + ) + + # Get or create the system API key + api_key_store = ApiKeyStore.get_instance() + + try: + api_key = await api_key_store.get_or_create_system_api_key( + user_id=user_id, + org_id=org_id, + name=request.name, + ) + except Exception as e: + logger.exception( + 'Failed to get or create system API key', + extra={ + 'user_id': user_id, + 'org_id': str(org_id), + 'error': str(e), + }, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to get or create API key', + ) + + logger.info( + 'Service created API key for user', + extra={ + 'service_id': service_id, + 'user_id': user_id, + 'org_id': str(org_id), + 'key_name': request.name, + }, + ) + + return CreateUserApiKeyResponse( + key=api_key, + user_id=user_id, + org_id=str(org_id), + name=request.name, + ) + + +@service_router.delete('/users/{user_id}/orgs/{org_id}/api-keys/{key_name}') +async def delete_user_api_key( + user_id: str, + org_id: UUID, + key_name: str, + x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'), +) -> dict: + """ + Delete a system API key created by the service. + + This endpoint allows the automations service to clean up API keys + it previously created for users. + + Args: + user_id: The user ID + org_id: The organization ID + key_name: The name of the key to delete (without __SYSTEM__: prefix) + x_service_api_key: Service API key header for authentication + + Returns: + dict: Success message + + Raises: + HTTPException: 401 if service key is invalid + HTTPException: 404 if key not found + """ + # Validate service API key + service_id = await validate_service_api_key(x_service_api_key) + + api_key_store = ApiKeyStore.get_instance() + + # Delete the key by name (wrap with system key prefix since service creates system keys) + system_key_name = api_key_store.make_system_key_name(key_name) + success = await api_key_store.delete_api_key_by_name( + user_id=user_id, + org_id=org_id, + name=system_key_name, + allow_system=True, + ) + + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f'API key with name "{key_name}" not found for user {user_id} in org {org_id}', + ) + + logger.info( + 'Service deleted API key for user', + extra={ + 'service_id': service_id, + 'user_id': user_id, + 'org_id': str(org_id), + 'key_name': key_name, + }, + ) + + return {'message': 'API key deleted successfully'} diff --git a/enterprise/storage/api_key_store.py b/enterprise/storage/api_key_store.py index 3090b8da07..ecbb375592 100644 --- a/enterprise/storage/api_key_store.py +++ b/enterprise/storage/api_key_store.py @@ -27,6 +27,9 @@ class ApiKeyValidationResult: @dataclass class ApiKeyStore: API_KEY_PREFIX = 'sk-oh-' + # Prefix for system keys created by internal services (e.g., automations) + # Keys with this prefix are hidden from users and cannot be deleted by users + SYSTEM_KEY_NAME_PREFIX = '__SYSTEM__:' def generate_api_key(self, length: int = 32) -> str: """Generate a random API key with the sk-oh- prefix.""" @@ -34,6 +37,19 @@ class ApiKeyStore: random_part = ''.join(secrets.choice(alphabet) for _ in range(length)) return f'{self.API_KEY_PREFIX}{random_part}' + @classmethod + def is_system_key_name(cls, name: str | None) -> bool: + """Check if a key name indicates a system key.""" + return name is not None and name.startswith(cls.SYSTEM_KEY_NAME_PREFIX) + + @classmethod + def make_system_key_name(cls, name: str) -> str: + """Create a system key name with the appropriate prefix. + + Format: __SYSTEM__: + """ + return f'{cls.SYSTEM_KEY_NAME_PREFIX}{name}' + async def create_api_key( self, user_id: str, name: str | None = None, expires_at: datetime | None = None ) -> str: @@ -71,6 +87,113 @@ class ApiKeyStore: return api_key + async def get_or_create_system_api_key( + self, + user_id: str, + org_id: UUID, + name: str, + ) -> str: + """Get or create a system API key for a user on behalf of an internal service. + + If a key with the given name already exists for this user/org and is not expired, + returns the existing key. Otherwise, creates a new key (and deletes any expired one). + + System keys are: + - Not visible to users in their API keys list (filtered by name prefix) + - Not deletable by users (protected by name prefix check) + - Associated with a specific org (not the user's current org) + - Never expire (no expiration date) + + Args: + user_id: The ID of the user to create the key for + org_id: The organization ID to associate the key with + name: Required name for the key (will be prefixed with __SYSTEM__:) + + Returns: + The API key (existing or newly created) + """ + # Create system key name with prefix + system_key_name = self.make_system_key_name(name) + + async with a_session_maker() as session: + # Check if key already exists for this user/org/name + result = await session.execute( + select(ApiKey).filter( + ApiKey.user_id == user_id, + ApiKey.org_id == org_id, + ApiKey.name == system_key_name, + ) + ) + existing_key = result.scalars().first() + + if existing_key: + # Check if expired + if existing_key.expires_at: + now = datetime.now(UTC) + expires_at = existing_key.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=UTC) + + if expires_at < now: + # Key is expired, delete it and create new one + logger.info( + 'System API key expired, re-issuing', + extra={ + 'user_id': user_id, + 'org_id': str(org_id), + 'key_name': system_key_name, + }, + ) + await session.delete(existing_key) + await session.commit() + else: + # Key exists and is not expired, return it + logger.debug( + 'Returning existing system API key', + extra={ + 'user_id': user_id, + 'org_id': str(org_id), + 'key_name': system_key_name, + }, + ) + return existing_key.key + else: + # Key exists and has no expiration, return it + logger.debug( + 'Returning existing system API key', + extra={ + 'user_id': user_id, + 'org_id': str(org_id), + 'key_name': system_key_name, + }, + ) + return existing_key.key + + # Create new key (no expiration) + api_key = self.generate_api_key() + + async with a_session_maker() as session: + key_record = ApiKey( + key=api_key, + user_id=user_id, + org_id=org_id, + name=system_key_name, + expires_at=None, # System keys never expire + ) + session.add(key_record) + await session.commit() + + logger.info( + 'Created system API key', + extra={ + 'user_id': user_id, + 'org_id': str(org_id), + 'key_name': system_key_name, + }, + ) + + return api_key + async def validate_api_key(self, api_key: str) -> ApiKeyValidationResult | None: """Validate an API key and return the associated user_id and org_id if valid.""" now = datetime.now(UTC) @@ -121,8 +244,18 @@ class ApiKeyStore: return True - async def delete_api_key_by_id(self, key_id: int) -> bool: - """Delete an API key by its ID.""" + async def delete_api_key_by_id( + self, key_id: int, allow_system: bool = False + ) -> bool: + """Delete an API key by its ID. + + Args: + key_id: The ID of the key to delete + allow_system: If False (default), system keys cannot be deleted + + Returns: + True if the key was deleted, False if not found or is a protected system key + """ async with a_session_maker() as session: result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id)) key_record = result.scalars().first() @@ -130,13 +263,26 @@ class ApiKeyStore: if not key_record: return False + # Protect system keys from deletion unless explicitly allowed + if self.is_system_key_name(key_record.name) and not allow_system: + logger.warning( + 'Attempted to delete system API key', + extra={'key_id': key_id, 'user_id': key_record.user_id}, + ) + return False + await session.delete(key_record) await session.commit() return True async def list_api_keys(self, user_id: str) -> list[ApiKey]: - """List all API keys for a user.""" + """List all user-visible API keys for a user. + + This excludes: + - System keys (name starts with __SYSTEM__:) - created by internal services + - MCP_API_KEY - internal MCP key + """ user = await UserStore.get_user_by_id(user_id) if user is None: raise ValueError(f'User not found: {user_id}') @@ -145,11 +291,17 @@ class ApiKeyStore: async with a_session_maker() as session: result = await session.execute( select(ApiKey).filter( - ApiKey.user_id == user_id, ApiKey.org_id == org_id + ApiKey.user_id == user_id, + ApiKey.org_id == org_id, ) ) keys = result.scalars().all() - return [key for key in keys if key.name != 'MCP_API_KEY'] + # Filter out system keys and MCP_API_KEY + return [ + key + for key in keys + if key.name != 'MCP_API_KEY' and not self.is_system_key_name(key.name) + ] async def retrieve_mcp_api_key(self, user_id: str) -> str | None: user = await UserStore.get_user_by_id(user_id) @@ -179,17 +331,44 @@ class ApiKeyStore: key_record = result.scalars().first() return key_record.key if key_record else None - async def delete_api_key_by_name(self, user_id: str, name: str) -> bool: - """Delete an API key by name for a specific user.""" + async def delete_api_key_by_name( + self, + user_id: str, + name: str, + org_id: UUID | None = None, + allow_system: bool = False, + ) -> bool: + """Delete an API key by name for a specific user. + + Args: + user_id: The ID of the user whose key to delete + name: The name of the key to delete + org_id: Optional organization ID to filter by (required for system keys) + allow_system: If False (default), system keys cannot be deleted + + Returns: + True if the key was deleted, False if not found or is a protected system key + """ async with a_session_maker() as session: - result = await session.execute( - select(ApiKey).filter(ApiKey.user_id == user_id, ApiKey.name == name) - ) + # Build the query filters + filters = [ApiKey.user_id == user_id, ApiKey.name == name] + if org_id is not None: + filters.append(ApiKey.org_id == org_id) + + result = await session.execute(select(ApiKey).filter(*filters)) key_record = result.scalars().first() if not key_record: return False + # Protect system keys from deletion unless explicitly allowed + if self.is_system_key_name(key_record.name) and not allow_system: + logger.warning( + 'Attempted to delete system API key', + extra={'user_id': user_id, 'key_name': name}, + ) + return False + await session.delete(key_record) await session.commit() diff --git a/enterprise/tests/unit/routes/__init__.py b/enterprise/tests/unit/routes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/enterprise/tests/unit/routes/test_service.py b/enterprise/tests/unit/routes/test_service.py new file mode 100644 index 0000000000..a7156ec117 --- /dev/null +++ b/enterprise/tests/unit/routes/test_service.py @@ -0,0 +1,331 @@ +"""Unit tests for service API routes.""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException +from server.routes.service import ( + CreateUserApiKeyRequest, + delete_user_api_key, + get_or_create_api_key_for_user, + validate_service_api_key, +) + + +class TestValidateServiceApiKey: + """Test cases for validate_service_api_key.""" + + @pytest.mark.asyncio + async def test_valid_service_key(self): + """Test validation with valid service API key.""" + with patch( + 'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key' + ): + result = await validate_service_api_key('test-service-key') + assert result == 'automations-service' + + @pytest.mark.asyncio + async def test_missing_service_key(self): + """Test validation with missing service API key header.""" + with patch( + 'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key' + ): + with pytest.raises(HTTPException) as exc_info: + await validate_service_api_key(None) + assert exc_info.value.status_code == 401 + assert 'X-Service-API-Key header is required' in exc_info.value.detail + + @pytest.mark.asyncio + async def test_invalid_service_key(self): + """Test validation with invalid service API key.""" + with patch( + 'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key' + ): + with pytest.raises(HTTPException) as exc_info: + await validate_service_api_key('wrong-key') + assert exc_info.value.status_code == 401 + assert 'Invalid service API key' in exc_info.value.detail + + @pytest.mark.asyncio + async def test_service_auth_not_configured(self): + """Test validation when service auth is not configured.""" + with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', ''): + with pytest.raises(HTTPException) as exc_info: + await validate_service_api_key('any-key') + assert exc_info.value.status_code == 503 + assert 'Service authentication not configured' in exc_info.value.detail + + +class TestCreateUserApiKeyRequest: + """Test cases for CreateUserApiKeyRequest validation.""" + + def test_valid_request(self): + """Test valid request with all fields.""" + request = CreateUserApiKeyRequest( + name='automation', + ) + assert request.name == 'automation' + + def test_name_is_required(self): + """Test that name field is required.""" + with pytest.raises(ValueError): + CreateUserApiKeyRequest( + name='', # Empty name should fail + ) + + def test_name_is_stripped(self): + """Test that name field is stripped of whitespace.""" + request = CreateUserApiKeyRequest( + name=' automation ', + ) + assert request.name == 'automation' + + def test_whitespace_only_name_fails(self): + """Test that whitespace-only name fails validation.""" + with pytest.raises(ValueError): + CreateUserApiKeyRequest( + name=' ', + ) + + +class TestGetOrCreateApiKeyForUser: + """Test cases for get_or_create_api_key_for_user endpoint.""" + + @pytest.fixture + def valid_user_id(self): + """Return a valid user ID.""" + return '5594c7b6-f959-4b81-92e9-b09c206f5081' + + @pytest.fixture + def valid_org_id(self): + """Return a valid org ID.""" + return uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081') + + @pytest.fixture + def valid_request(self): + """Create a valid request object.""" + return CreateUserApiKeyRequest( + name='automation', + ) + + @pytest.mark.asyncio + async def test_user_not_found(self, valid_user_id, valid_org_id, valid_request): + """Test error when user doesn't exist.""" + with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'): + with patch( + 'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock + ) as mock_get_user: + mock_get_user.return_value = None + with pytest.raises(HTTPException) as exc_info: + await get_or_create_api_key_for_user( + user_id=valid_user_id, + org_id=valid_org_id, + request=valid_request, + x_service_api_key='test-key', + ) + assert exc_info.value.status_code == 404 + assert 'not found' in exc_info.value.detail + + @pytest.mark.asyncio + async def test_user_not_in_org(self, valid_user_id, valid_org_id, valid_request): + """Test error when user is not a member of the org.""" + mock_user = MagicMock() + + with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'): + with patch( + 'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock + ) as mock_get_user: + with patch( + 'server.routes.service.OrgMemberStore.get_org_member', + new_callable=AsyncMock, + ) as mock_get_member: + mock_get_user.return_value = mock_user + mock_get_member.return_value = None + with pytest.raises(HTTPException) as exc_info: + await get_or_create_api_key_for_user( + user_id=valid_user_id, + org_id=valid_org_id, + request=valid_request, + x_service_api_key='test-key', + ) + assert exc_info.value.status_code == 403 + assert 'not a member of org' in exc_info.value.detail + + @pytest.mark.asyncio + async def test_successful_key_creation( + self, valid_user_id, valid_org_id, valid_request + ): + """Test successful API key creation.""" + mock_user = MagicMock() + mock_org_member = MagicMock() + mock_api_key_store = MagicMock() + mock_api_key_store.get_or_create_system_api_key = AsyncMock( + return_value='sk-oh-test-key-12345678901234567890' + ) + + with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'): + with patch( + 'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock + ) as mock_get_user: + with patch( + 'server.routes.service.OrgMemberStore.get_org_member', + new_callable=AsyncMock, + ) as mock_get_member: + with patch( + 'server.routes.service.ApiKeyStore.get_instance' + ) as mock_get_store: + mock_get_user.return_value = mock_user + mock_get_member.return_value = mock_org_member + mock_get_store.return_value = mock_api_key_store + + response = await get_or_create_api_key_for_user( + user_id=valid_user_id, + org_id=valid_org_id, + request=valid_request, + x_service_api_key='test-key', + ) + + assert response.key == 'sk-oh-test-key-12345678901234567890' + assert response.user_id == valid_user_id + assert response.org_id == str(valid_org_id) + assert response.name == 'automation' + + # Verify the store was called with correct arguments + mock_api_key_store.get_or_create_system_api_key.assert_called_once_with( + user_id=valid_user_id, + org_id=valid_org_id, + name='automation', + ) + + @pytest.mark.asyncio + async def test_store_exception_handling( + self, valid_user_id, valid_org_id, valid_request + ): + """Test error handling when store raises exception.""" + mock_user = MagicMock() + mock_org_member = MagicMock() + mock_api_key_store = MagicMock() + mock_api_key_store.get_or_create_system_api_key = AsyncMock( + side_effect=Exception('Database error') + ) + + with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'): + with patch( + 'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock + ) as mock_get_user: + with patch( + 'server.routes.service.OrgMemberStore.get_org_member', + new_callable=AsyncMock, + ) as mock_get_member: + with patch( + 'server.routes.service.ApiKeyStore.get_instance' + ) as mock_get_store: + mock_get_user.return_value = mock_user + mock_get_member.return_value = mock_org_member + mock_get_store.return_value = mock_api_key_store + + with pytest.raises(HTTPException) as exc_info: + await get_or_create_api_key_for_user( + user_id=valid_user_id, + org_id=valid_org_id, + request=valid_request, + x_service_api_key='test-key', + ) + + assert exc_info.value.status_code == 500 + assert 'Failed to get or create API key' in exc_info.value.detail + + +class TestDeleteUserApiKey: + """Test cases for delete_user_api_key endpoint.""" + + @pytest.fixture + def valid_org_id(self): + """Return a valid org ID.""" + return uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081') + + @pytest.mark.asyncio + async def test_successful_delete(self, valid_org_id): + """Test successful deletion of a system API key.""" + mock_api_key_store = MagicMock() + mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:automation' + mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=True) + + with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'): + with patch( + 'server.routes.service.ApiKeyStore.get_instance' + ) as mock_get_store: + mock_get_store.return_value = mock_api_key_store + + response = await delete_user_api_key( + user_id='user-123', + org_id=valid_org_id, + key_name='automation', + x_service_api_key='test-key', + ) + + assert response == {'message': 'API key deleted successfully'} + + # Verify the store was called with correct arguments + mock_api_key_store.make_system_key_name.assert_called_once_with('automation') + mock_api_key_store.delete_api_key_by_name.assert_called_once_with( + user_id='user-123', + org_id=valid_org_id, + name='__SYSTEM__:automation', + allow_system=True, + ) + + @pytest.mark.asyncio + async def test_delete_key_not_found(self, valid_org_id): + """Test error when key to delete is not found.""" + mock_api_key_store = MagicMock() + mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:nonexistent' + mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=False) + + with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'): + with patch( + 'server.routes.service.ApiKeyStore.get_instance' + ) as mock_get_store: + mock_get_store.return_value = mock_api_key_store + + with pytest.raises(HTTPException) as exc_info: + await delete_user_api_key( + user_id='user-123', + org_id=valid_org_id, + key_name='nonexistent', + x_service_api_key='test-key', + ) + + assert exc_info.value.status_code == 404 + assert 'not found' in exc_info.value.detail + + @pytest.mark.asyncio + async def test_delete_invalid_service_key(self, valid_org_id): + """Test error when service API key is invalid.""" + with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'): + with pytest.raises(HTTPException) as exc_info: + await delete_user_api_key( + user_id='user-123', + org_id=valid_org_id, + key_name='automation', + x_service_api_key='wrong-key', + ) + + assert exc_info.value.status_code == 401 + assert 'Invalid service API key' in exc_info.value.detail + + @pytest.mark.asyncio + async def test_delete_missing_service_key(self, valid_org_id): + """Test error when service API key header is missing.""" + with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'): + with pytest.raises(HTTPException) as exc_info: + await delete_user_api_key( + user_id='user-123', + org_id=valid_org_id, + key_name='automation', + x_service_api_key=None, + ) + + assert exc_info.value.status_code == 401 + assert 'X-Service-API-Key header is required' in exc_info.value.detail diff --git a/enterprise/tests/unit/storage/test_api_key_store.py b/enterprise/tests/unit/storage/test_api_key_store.py new file mode 100644 index 0000000000..0db2d8bb96 --- /dev/null +++ b/enterprise/tests/unit/storage/test_api_key_store.py @@ -0,0 +1,314 @@ +"""Unit tests for ApiKeyStore system key functionality.""" + +import uuid +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy import select +from storage.api_key import ApiKey +from storage.api_key_store import ApiKeyStore + + +@pytest.fixture +def api_key_store(): + """Create ApiKeyStore instance.""" + return ApiKeyStore() + + +class TestApiKeyStoreSystemKeys: + """Test cases for system API key functionality.""" + + def test_is_system_key_name_with_prefix(self, api_key_store): + """Test that names with __SYSTEM__: prefix are identified as system keys.""" + assert api_key_store.is_system_key_name('__SYSTEM__:automation') is True + assert api_key_store.is_system_key_name('__SYSTEM__:test-key') is True + assert api_key_store.is_system_key_name('__SYSTEM__:') is True + + def test_is_system_key_name_without_prefix(self, api_key_store): + """Test that names without __SYSTEM__: prefix are not system keys.""" + assert api_key_store.is_system_key_name('my-key') is False + assert api_key_store.is_system_key_name('automation') is False + assert api_key_store.is_system_key_name('MCP_API_KEY') is False + assert api_key_store.is_system_key_name('') is False + + def test_is_system_key_name_none(self, api_key_store): + """Test that None is not a system key.""" + assert api_key_store.is_system_key_name(None) is False + + def test_make_system_key_name(self, api_key_store): + """Test system key name generation.""" + assert ( + api_key_store.make_system_key_name('automation') == '__SYSTEM__:automation' + ) + assert api_key_store.make_system_key_name('test-key') == '__SYSTEM__:test-key' + + @pytest.mark.asyncio + async def test_get_or_create_system_api_key_creates_new( + self, api_key_store, async_session_maker + ): + """Test creating a new system API key when none exists.""" + user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081' + org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081') + key_name = 'automation' + + with patch('storage.api_key_store.a_session_maker', async_session_maker): + api_key = await api_key_store.get_or_create_system_api_key( + user_id=user_id, + org_id=org_id, + name=key_name, + ) + + assert api_key.startswith('sk-oh-') + assert len(api_key) == len('sk-oh-') + 32 + + # Verify the key was created in the database + async with async_session_maker() as session: + result = await session.execute(select(ApiKey).filter(ApiKey.key == api_key)) + key_record = result.scalars().first() + assert key_record is not None + assert key_record.user_id == user_id + assert key_record.org_id == org_id + assert key_record.name == '__SYSTEM__:automation' + assert key_record.expires_at is None # System keys never expire + + @pytest.mark.asyncio + async def test_get_or_create_system_api_key_returns_existing( + self, api_key_store, async_session_maker + ): + """Test that existing valid system key is returned.""" + user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081' + org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081') + key_name = 'automation' + + with patch('storage.api_key_store.a_session_maker', async_session_maker): + # Create the first key + first_key = await api_key_store.get_or_create_system_api_key( + user_id=user_id, + org_id=org_id, + name=key_name, + ) + + # Request again - should return the same key + second_key = await api_key_store.get_or_create_system_api_key( + user_id=user_id, + org_id=org_id, + name=key_name, + ) + + assert first_key == second_key + + @pytest.mark.asyncio + async def test_get_or_create_system_api_key_different_names( + self, api_key_store, async_session_maker + ): + """Test that different names create different keys.""" + user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081' + org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081') + + with patch('storage.api_key_store.a_session_maker', async_session_maker): + key1 = await api_key_store.get_or_create_system_api_key( + user_id=user_id, + org_id=org_id, + name='automation-1', + ) + + key2 = await api_key_store.get_or_create_system_api_key( + user_id=user_id, + org_id=org_id, + name='automation-2', + ) + + assert key1 != key2 + + @pytest.mark.asyncio + async def test_get_or_create_system_api_key_reissues_expired( + self, api_key_store, async_session_maker + ): + """Test that expired system key is replaced with a new one.""" + user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081' + org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081') + key_name = 'automation' + system_key_name = '__SYSTEM__:automation' + + # First, manually create an expired key + expired_time = datetime.now(UTC) - timedelta(hours=1) + async with async_session_maker() as session: + expired_key = ApiKey( + key='sk-oh-expired-key-12345678901234567890', + user_id=user_id, + org_id=org_id, + name=system_key_name, + expires_at=expired_time.replace(tzinfo=None), + ) + session.add(expired_key) + await session.commit() + + with patch('storage.api_key_store.a_session_maker', async_session_maker): + # Request the key - should create a new one + new_key = await api_key_store.get_or_create_system_api_key( + user_id=user_id, + org_id=org_id, + name=key_name, + ) + + assert new_key != 'sk-oh-expired-key-12345678901234567890' + assert new_key.startswith('sk-oh-') + + # Verify old key was deleted and new key exists + async with async_session_maker() as session: + result = await session.execute( + select(ApiKey).filter(ApiKey.name == system_key_name) + ) + keys = result.scalars().all() + assert len(keys) == 1 + assert keys[0].key == new_key + assert keys[0].expires_at is None + + @pytest.mark.asyncio + async def test_list_api_keys_excludes_system_keys( + self, api_key_store, async_session_maker + ): + """Test that list_api_keys excludes system keys.""" + user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081' + org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081') + + # Create a user key and a system key + async with async_session_maker() as session: + user_key = ApiKey( + key='sk-oh-user-key-123456789012345678901', + user_id=user_id, + org_id=org_id, + name='my-user-key', + ) + system_key = ApiKey( + key='sk-oh-system-key-12345678901234567890', + user_id=user_id, + org_id=org_id, + name='__SYSTEM__:automation', + ) + mcp_key = ApiKey( + key='sk-oh-mcp-key-1234567890123456789012', + user_id=user_id, + org_id=org_id, + name='MCP_API_KEY', + ) + session.add(user_key) + session.add(system_key) + session.add(mcp_key) + await session.commit() + + # Mock UserStore.get_user_by_id to return a user with the correct org + mock_user = MagicMock() + mock_user.current_org_id = org_id + + with patch('storage.api_key_store.a_session_maker', async_session_maker): + with patch( + 'storage.api_key_store.UserStore.get_user_by_id', new_callable=AsyncMock + ) as mock_get_user: + mock_get_user.return_value = mock_user + keys = await api_key_store.list_api_keys(user_id) + + # Should only return the user key + assert len(keys) == 1 + assert keys[0].name == 'my-user-key' + + @pytest.mark.asyncio + async def test_delete_api_key_by_id_protects_system_keys( + self, api_key_store, async_session_maker + ): + """Test that system keys cannot be deleted by users.""" + user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081' + org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081') + + # Create a system key + async with async_session_maker() as session: + system_key = ApiKey( + key='sk-oh-system-key-12345678901234567890', + user_id=user_id, + org_id=org_id, + name='__SYSTEM__:automation', + ) + session.add(system_key) + await session.commit() + key_id = system_key.id + + with patch('storage.api_key_store.a_session_maker', async_session_maker): + # Attempt to delete without allow_system flag + result = await api_key_store.delete_api_key_by_id( + key_id, allow_system=False + ) + + assert result is False + + # Verify the key still exists + async with async_session_maker() as session: + result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id)) + key_record = result.scalars().first() + assert key_record is not None + + @pytest.mark.asyncio + async def test_delete_api_key_by_id_allows_system_with_flag( + self, api_key_store, async_session_maker + ): + """Test that system keys can be deleted with allow_system=True.""" + user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081' + org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081') + + # Create a system key + async with async_session_maker() as session: + system_key = ApiKey( + key='sk-oh-system-key-12345678901234567890', + user_id=user_id, + org_id=org_id, + name='__SYSTEM__:automation', + ) + session.add(system_key) + await session.commit() + key_id = system_key.id + + with patch('storage.api_key_store.a_session_maker', async_session_maker): + # Delete with allow_system=True + result = await api_key_store.delete_api_key_by_id(key_id, allow_system=True) + + assert result is True + + # Verify the key was deleted + async with async_session_maker() as session: + result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id)) + key_record = result.scalars().first() + assert key_record is None + + @pytest.mark.asyncio + async def test_delete_api_key_by_id_allows_regular_keys( + self, api_key_store, async_session_maker + ): + """Test that regular keys can be deleted normally.""" + user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081' + org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081') + + # Create a regular key + async with async_session_maker() as session: + regular_key = ApiKey( + key='sk-oh-regular-key-1234567890123456789', + user_id=user_id, + org_id=org_id, + name='my-regular-key', + ) + session.add(regular_key) + await session.commit() + key_id = regular_key.id + + with patch('storage.api_key_store.a_session_maker', async_session_maker): + # Delete without allow_system flag - should work for regular keys + result = await api_key_store.delete_api_key_by_id( + key_id, allow_system=False + ) + + assert result is True + + # Verify the key was deleted + async with async_session_maker() as session: + result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id)) + key_record = result.scalars().first() + assert key_record is None From 2879e587813b8a128c155d9e2bbcece862b6153c Mon Sep 17 00:00:00 2001 From: aivong-openhands Date: Wed, 18 Mar 2026 15:00:06 -0500 Subject: [PATCH 69/92] Fix CVE-2026-30922: Update pyasn1 to 0.6.3 (#13452) Co-authored-by: OpenHands CVE Fix Bot --- enterprise/poetry.lock | 6 +++--- poetry.lock | 6 +++--- uv.lock | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index 589be34bb0..1bb48f24c6 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -7597,14 +7597,14 @@ wrappers-encryption = ["cryptography (>=45.0.0)"] [[package]] name = "pyasn1" -version = "0.6.2" +version = "0.6.3" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, - {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, + {file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"}, + {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"}, ] [[package]] diff --git a/poetry.lock b/poetry.lock index 5b0b30f61d..bccd0eea80 100644 --- a/poetry.lock +++ b/poetry.lock @@ -7589,14 +7589,14 @@ wrappers-encryption = ["cryptography (>=45.0.0)"] [[package]] name = "pyasn1" -version = "0.6.2" +version = "0.6.3" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, - {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, + {file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"}, + {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"}, ] [[package]] diff --git a/uv.lock b/uv.lock index aec35e87db..67c7965698 100644 --- a/uv.lock +++ b/uv.lock @@ -4643,11 +4643,11 @@ memory = [ [[package]] name = "pyasn1" -version = "0.6.2" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] From abd1f9948f888c73440b94334e57771aabee5556 Mon Sep 17 00:00:00 2001 From: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:46:00 -0700 Subject: [PATCH 70/92] fix: return empty skills list instead of 404 for stopped sandboxes (#13429) Co-authored-by: openhands --- .../app_conversation_router.py | 26 ++++++++--- .../test_app_conversation_hooks_endpoint.py | 43 ++++++++++++++++++- .../test_app_conversation_skills_endpoint.py | 21 +++------ 3 files changed, 69 insertions(+), 21 deletions(-) diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py index d3ad901db7..582de93761 100644 --- a/openhands/app_server/app_conversation/app_conversation_router.py +++ b/openhands/app_server/app_conversation/app_conversation_router.py @@ -115,7 +115,7 @@ async def _get_agent_server_context( app_conversation_service: AppConversationService, sandbox_service: SandboxService, sandbox_spec_service: SandboxSpecService, -) -> AgentServerContext | JSONResponse: +) -> AgentServerContext | JSONResponse | None: """Get the agent server context for a conversation. This helper retrieves all necessary information to communicate with the @@ -129,7 +129,8 @@ async def _get_agent_server_context( sandbox_spec_service: Service for sandbox spec operations Returns: - AgentServerContext if successful, or JSONResponse with error details. + AgentServerContext if successful, JSONResponse(404) if conversation + not found, or None if sandbox is not running (e.g. closed conversation). """ # Get the conversation info conversation = await app_conversation_service.get_app_conversation(conversation_id) @@ -141,12 +142,19 @@ async def _get_agent_server_context( # Get the sandbox info sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id) - if not sandbox or sandbox.status != SandboxStatus.RUNNING: + if not sandbox: return JSONResponse( status_code=status.HTTP_404_NOT_FOUND, - content={ - 'error': f'Sandbox not found or not running for conversation {conversation_id}' - }, + content={'error': f'Sandbox not found for conversation {conversation_id}'}, + ) + # Return None for paused sandboxes (closed conversation) + if sandbox.status == SandboxStatus.PAUSED: + return None + # Return 404 for other non-running states (STARTING, ERROR, MISSING) + if sandbox.status != SandboxStatus.RUNNING: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={'error': f'Sandbox not ready for conversation {conversation_id}'}, ) # Get the sandbox spec to find the working directory @@ -587,6 +595,7 @@ async def get_conversation_skills( Returns: JSONResponse: A JSON response containing the list of skills. + Returns an empty list if the sandbox is not running. """ try: # Get agent server context (conversation, sandbox, sandbox_spec, agent_server_url) @@ -598,6 +607,8 @@ async def get_conversation_skills( ) if isinstance(ctx, JSONResponse): return ctx + if ctx is None: + return JSONResponse(status_code=status.HTTP_200_OK, content={'skills': []}) # Load skills from all sources logger.info(f'Loading skills for conversation {conversation_id}') @@ -685,6 +696,7 @@ async def get_conversation_hooks( Returns: JSONResponse: A JSON response containing the list of hook event types. + Returns an empty list if the sandbox is not running. """ try: # Get agent server context (conversation, sandbox, sandbox_spec, agent_server_url) @@ -696,6 +708,8 @@ async def get_conversation_hooks( ) if isinstance(ctx, JSONResponse): return ctx + if ctx is None: + return JSONResponse(status_code=status.HTTP_200_OK, content={'hooks': []}) from openhands.app_server.app_conversation.hook_loader import ( fetch_hooks_from_agent_server, diff --git a/tests/unit/app_server/test_app_conversation_hooks_endpoint.py b/tests/unit/app_server/test_app_conversation_hooks_endpoint.py index ba67c4b488..ffc8c54d37 100644 --- a/tests/unit/app_server/test_app_conversation_hooks_endpoint.py +++ b/tests/unit/app_server/test_app_conversation_hooks_endpoint.py @@ -263,7 +263,7 @@ class TestGetConversationHooks: assert response.status_code == status.HTTP_404_NOT_FOUND - async def test_get_hooks_returns_404_when_sandbox_not_running(self): + async def test_get_hooks_returns_404_when_sandbox_not_found(self): conversation_id = uuid4() sandbox_id = str(uuid4()) @@ -291,3 +291,44 @@ class TestGetConversationHooks: ) assert response.status_code == status.HTTP_404_NOT_FOUND + + async def test_get_hooks_returns_empty_list_when_sandbox_paused(self): + conversation_id = uuid4() + sandbox_id = str(uuid4()) + + mock_conversation = AppConversation( + id=conversation_id, + created_by_user_id='test-user', + sandbox_id=sandbox_id, + sandbox_status=SandboxStatus.PAUSED, + ) + + mock_sandbox = SandboxInfo( + id=sandbox_id, + created_by_user_id='test-user', + status=SandboxStatus.PAUSED, + sandbox_spec_id=str(uuid4()), + session_api_key='test-api-key', + ) + + mock_app_conversation_service = MagicMock() + mock_app_conversation_service.get_app_conversation = AsyncMock( + return_value=mock_conversation + ) + + mock_sandbox_service = MagicMock() + mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox) + + response = await get_conversation_hooks( + conversation_id=conversation_id, + app_conversation_service=mock_app_conversation_service, + sandbox_service=mock_sandbox_service, + sandbox_spec_service=MagicMock(), + httpx_client=AsyncMock(spec=httpx.AsyncClient), + ) + + assert response.status_code == status.HTTP_200_OK + import json + + data = json.loads(response.body.decode('utf-8')) + assert data == {'hooks': []} diff --git a/tests/unit/app_server/test_app_conversation_skills_endpoint.py b/tests/unit/app_server/test_app_conversation_skills_endpoint.py index ed7fedd43d..6b601cf9db 100644 --- a/tests/unit/app_server/test_app_conversation_skills_endpoint.py +++ b/tests/unit/app_server/test_app_conversation_skills_endpoint.py @@ -203,7 +203,7 @@ class TestGetConversationSkills: Arrange: Setup conversation but no sandbox Act: Call get_conversation_skills endpoint - Assert: Response is 404 with sandbox error message + Assert: Response is 404 """ # Arrange conversation_id = uuid4() @@ -237,19 +237,13 @@ class TestGetConversationSkills: # Assert assert response.status_code == status.HTTP_404_NOT_FOUND - content = response.body.decode('utf-8') - import json - data = json.loads(content) - assert 'error' in data - assert 'Sandbox not found' in data['error'] + async def test_get_skills_returns_empty_list_when_sandbox_paused(self): + """Test endpoint returns empty skills when sandbox is PAUSED (closed conversation). - async def test_get_skills_returns_404_when_sandbox_not_running(self): - """Test endpoint returns 404 when sandbox is not in RUNNING state. - - Arrange: Setup conversation with stopped sandbox + Arrange: Setup conversation with paused sandbox Act: Call get_conversation_skills endpoint - Assert: Response is 404 with sandbox not running message + Assert: Response is 200 with empty skills list """ # Arrange conversation_id = uuid4() @@ -290,13 +284,12 @@ class TestGetConversationSkills: ) # Assert - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_200_OK content = response.body.decode('utf-8') import json data = json.loads(content) - assert 'error' in data - assert 'not running' in data['error'] + assert data == {'skills': []} async def test_get_skills_handles_task_trigger_skills(self): """Test endpoint correctly handles skills with TaskTrigger. From 7edebcbc0c09fd0edc6c666b327919f267e12197 Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Wed, 18 Mar 2026 16:49:32 -0600 Subject: [PATCH 71/92] fix: use atomic write in LocalFileStore to prevent race conditions (#13480) Co-authored-by: openhands Co-authored-by: OpenHands Bot --- openhands/storage/local.py | 17 ++++++++-- tests/unit/storage/test_storage.py | 52 ++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/openhands/storage/local.py b/openhands/storage/local.py index fcb766c0ef..e646f3137e 100644 --- a/openhands/storage/local.py +++ b/openhands/storage/local.py @@ -1,5 +1,6 @@ import os import shutil +import threading from openhands.core.logger import openhands_logger as logger from openhands.storage.files import FileStore @@ -23,8 +24,20 @@ class LocalFileStore(FileStore): full_path = self.get_full_path(path) os.makedirs(os.path.dirname(full_path), exist_ok=True) mode = 'w' if isinstance(contents, str) else 'wb' - with open(full_path, mode) as f: - f.write(contents) + + # Use atomic write: write to temp file, then rename + # This prevents race conditions where concurrent writes could corrupt the file + temp_path = f'{full_path}.tmp.{os.getpid()}.{threading.get_ident()}' + try: + with open(temp_path, mode) as f: + f.write(contents) + f.flush() + os.fsync(f.fileno()) + os.replace(temp_path, full_path) + except Exception: + if os.path.exists(temp_path): + os.remove(temp_path) + raise def read(self, path: str) -> str: full_path = self.get_full_path(path) diff --git a/tests/unit/storage/test_storage.py b/tests/unit/storage/test_storage.py index a78c12df98..5d2508705f 100644 --- a/tests/unit/storage/test_storage.py +++ b/tests/unit/storage/test_storage.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import shutil import tempfile +import threading from abc import ABC from dataclasses import dataclass, field from io import BytesIO, StringIO @@ -122,6 +123,57 @@ class TestLocalFileStore(TestCase, _StorageTest): f'Failed to remove temporary directory {self.temp_dir}: {e}' ) + def test_concurrent_writes_no_corruption(self): + """Test that concurrent writes don't corrupt file content. + + This test verifies the atomic write fix by having 9 threads write + progressively shorter strings to the same file simultaneously. + Without atomic writes, a shorter write following a longer write + could result in corrupted content (e.g., "123" followed by garbage + from the previous longer write). + + The final content must be exactly one of the valid strings written, + with no trailing garbage from other writes. + """ + filename = 'concurrent_test.txt' + # Strings from longest to shortest: "123456789", "12345678", ..., "1" + valid_contents = ['123456789'[:i] for i in range(9, 0, -1)] + errors: list[Exception] = [] + barrier = threading.Barrier(len(valid_contents)) + + def write_content(content: str): + try: + # Wait for all threads to be ready before writing + barrier.wait() + self.store.write(filename, content) + except Exception as e: + errors.append(e) + + # Start all threads + threads = [ + threading.Thread(target=write_content, args=(content,)) + for content in valid_contents + ] + for t in threads: + t.start() + for t in threads: + t.join() + + # Check for errors during writes + self.assertEqual( + errors, [], f'Errors occurred during concurrent writes: {errors}' + ) + + # Read final content and verify it's one of the valid strings + final_content = self.store.read(filename) + self.assertIn( + final_content, + valid_contents, + f"File content '{final_content}' is not one of the valid strings. " + f'Length: {len(final_content)}. This indicates file corruption from ' + f'concurrent writes (e.g., shorter write did not fully replace longer write).', + ) + class TestInMemoryFileStore(TestCase, _StorageTest): def setUp(self): From dcb2e21b87b87d9a52c23c8a44f3d907f0648f60 Mon Sep 17 00:00:00 2001 From: Saurya Velagapudi Date: Wed, 18 Mar 2026 17:07:19 -0700 Subject: [PATCH 72/92] feat: Auto-forward LLM_* env vars to agent-server and fix host network config (#13192) Co-authored-by: openhands --- .../sandbox/docker_sandbox_service.py | 21 +- .../sandbox/sandbox_spec_service.py | 53 ++- .../test_agent_server_env_override.py | 422 +++++++++++++++++- .../app_server/test_docker_sandbox_service.py | 53 +++ 4 files changed, 528 insertions(+), 21 deletions(-) diff --git a/openhands/app_server/sandbox/docker_sandbox_service.py b/openhands/app_server/sandbox/docker_sandbox_service.py index 6c692a680a..f5a302fa73 100644 --- a/openhands/app_server/sandbox/docker_sandbox_service.py +++ b/openhands/app_server/sandbox/docker_sandbox_service.py @@ -43,6 +43,16 @@ _logger = logging.getLogger(__name__) STARTUP_GRACE_SECONDS = 15 +def _get_use_host_network_default() -> bool: + """Get the default value for use_host_network from environment variables. + + This function is called at runtime (not at class definition time) to ensure + that environment variable changes are picked up correctly. + """ + value = os.getenv('AGENT_SERVER_USE_HOST_NETWORK', '') + return value.lower() in ('true', '1', 'yes') + + class VolumeMount(BaseModel): """Mounted volume within the container.""" @@ -591,18 +601,13 @@ class DockerSandboxServiceInjector(SandboxServiceInjector): ), ) use_host_network: bool = Field( - default=os.getenv('SANDBOX_USE_HOST_NETWORK', '').lower() - in ( - 'true', - '1', - 'yes', - ), + default_factory=_get_use_host_network_default, description=( - 'Whether to use host networking mode for sandbox containers. ' + 'Whether to use host networking mode for agent-server containers. ' 'When enabled, containers share the host network namespace, ' 'making all container ports directly accessible on the host. ' 'This is useful for reverse proxy setups where dynamic port mapping ' - 'is problematic. Configure via OH_SANDBOX_USE_HOST_NETWORK environment variable.' + 'is problematic. Configure via AGENT_SERVER_USE_HOST_NETWORK environment variable.' ), ) diff --git a/openhands/app_server/sandbox/sandbox_spec_service.py b/openhands/app_server/sandbox/sandbox_spec_service.py index 4034af1f5b..5025bdee6b 100644 --- a/openhands/app_server/sandbox/sandbox_spec_service.py +++ b/openhands/app_server/sandbox/sandbox_spec_service.py @@ -69,27 +69,58 @@ def get_agent_server_image() -> str: return AGENT_SERVER_IMAGE +# Prefixes for environment variables that should be auto-forwarded to agent-server +# These are typically configuration variables that affect the agent's behavior +AUTO_FORWARD_PREFIXES = ('LLM_',) + + def get_agent_server_env() -> dict[str, str]: """Get environment variables to be injected into agent server sandbox environments. - This function reads environment variable overrides from the OH_AGENT_SERVER_ENV - environment variable, which should contain a JSON string mapping variable names - to their values. + This function combines two sources of environment variables: + + 1. **Auto-forwarded variables**: Environment variables with certain prefixes + (e.g., LLM_*) are automatically forwarded to the agent-server container. + This ensures that LLM configuration like timeouts and retry settings + work correctly in the two-container V1 architecture. + + 2. **Explicit overrides via OH_AGENT_SERVER_ENV**: A JSON string that allows + setting arbitrary environment variables in the agent-server container. + Values set here take precedence over auto-forwarded variables. + + Auto-forwarded prefixes: + - LLM_* : LLM configuration (timeout, retries, model settings, etc.) Usage: - Set OH_AGENT_SERVER_ENV to a JSON string: - OH_AGENT_SERVER_ENV='{"DEBUG": "true", "LOG_LEVEL": "info", "CUSTOM_VAR": "value"}' + # Auto-forwarding (no action needed): + export LLM_TIMEOUT=3600 + export LLM_NUM_RETRIES=10 + # These will automatically be available in the agent-server - This will inject the following environment variables into all sandbox environments: - - DEBUG=true - - LOG_LEVEL=info - - CUSTOM_VAR=value + # Explicit override via JSON: + OH_AGENT_SERVER_ENV='{"DEBUG": "true", "CUSTOM_VAR": "value"}' + + # Override an auto-forwarded variable: + export LLM_TIMEOUT=3600 # Would be auto-forwarded as 3600 + OH_AGENT_SERVER_ENV='{"LLM_TIMEOUT": "7200"}' # Overrides to 7200 Returns: dict[str, str]: Dictionary of environment variable names to values. - Returns empty dict if OH_AGENT_SERVER_ENV is not set or invalid. + Returns empty dict if no variables are found. Raises: JSONDecodeError: If OH_AGENT_SERVER_ENV contains invalid JSON. """ - return env_parser.from_env(dict[str, str], 'OH_AGENT_SERVER_ENV') + result: dict[str, str] = {} + + # Step 1: Auto-forward environment variables with recognized prefixes + for key, value in os.environ.items(): + if any(key.startswith(prefix) for prefix in AUTO_FORWARD_PREFIXES): + result[key] = value + + # Step 2: Apply explicit overrides from OH_AGENT_SERVER_ENV + # These take precedence over auto-forwarded variables + explicit_env = env_parser.from_env(dict[str, str], 'OH_AGENT_SERVER_ENV') + result.update(explicit_env) + + return result diff --git a/tests/unit/app_server/test_agent_server_env_override.py b/tests/unit/app_server/test_agent_server_env_override.py index 61d851590e..5c1c1ea208 100644 --- a/tests/unit/app_server/test_agent_server_env_override.py +++ b/tests/unit/app_server/test_agent_server_env_override.py @@ -2,10 +2,11 @@ This module tests the environment variable override functionality that allows users to inject custom environment variables into sandbox environments via -OH_AGENT_SERVER_ENV_* environment variables. +OH_AGENT_SERVER_ENV environment variable and auto-forwarding of LLM_* variables. The functionality includes: -- Parsing OH_AGENT_SERVER_ENV_* environment variables +- Auto-forwarding of LLM_* environment variables to agent-server containers +- Explicit overrides via OH_AGENT_SERVER_ENV JSON - Merging them into sandbox specifications - Integration across different sandbox types (Docker, Process, Remote) """ @@ -25,6 +26,7 @@ from openhands.app_server.sandbox.remote_sandbox_spec_service import ( get_default_sandbox_specs as get_default_remote_sandbox_specs, ) from openhands.app_server.sandbox.sandbox_spec_service import ( + AUTO_FORWARD_PREFIXES, get_agent_server_env, ) @@ -185,6 +187,114 @@ class TestGetAgentServerEnv: assert result == expected +class TestLLMAutoForwarding: + """Test cases for automatic forwarding of LLM_* environment variables.""" + + def test_auto_forward_prefixes_contains_llm(self): + """Test that LLM_ is in the auto-forward prefixes.""" + assert 'LLM_' in AUTO_FORWARD_PREFIXES + + def test_llm_timeout_auto_forwarded(self): + """Test that LLM_TIMEOUT is automatically forwarded.""" + env_vars = { + 'LLM_TIMEOUT': '3600', + 'OTHER_VAR': 'should_not_be_included', + } + + with patch.dict(os.environ, env_vars, clear=True): + result = get_agent_server_env() + assert 'LLM_TIMEOUT' in result + assert result['LLM_TIMEOUT'] == '3600' + assert 'OTHER_VAR' not in result + + def test_llm_num_retries_auto_forwarded(self): + """Test that LLM_NUM_RETRIES is automatically forwarded.""" + env_vars = { + 'LLM_NUM_RETRIES': '10', + } + + with patch.dict(os.environ, env_vars, clear=True): + result = get_agent_server_env() + assert 'LLM_NUM_RETRIES' in result + assert result['LLM_NUM_RETRIES'] == '10' + + def test_multiple_llm_vars_auto_forwarded(self): + """Test that multiple LLM_* variables are automatically forwarded.""" + env_vars = { + 'LLM_TIMEOUT': '3600', + 'LLM_NUM_RETRIES': '10', + 'LLM_MODEL': 'gpt-4', + 'LLM_BASE_URL': 'https://api.example.com', + 'LLM_API_KEY': 'secret-key', + 'NON_LLM_VAR': 'should_not_be_included', + } + + with patch.dict(os.environ, env_vars, clear=True): + result = get_agent_server_env() + assert result['LLM_TIMEOUT'] == '3600' + assert result['LLM_NUM_RETRIES'] == '10' + assert result['LLM_MODEL'] == 'gpt-4' + assert result['LLM_BASE_URL'] == 'https://api.example.com' + assert result['LLM_API_KEY'] == 'secret-key' + assert 'NON_LLM_VAR' not in result + + def test_explicit_override_takes_precedence(self): + """Test that OH_AGENT_SERVER_ENV overrides auto-forwarded variables.""" + env_vars = { + 'LLM_TIMEOUT': '3600', # Auto-forwarded value + 'OH_AGENT_SERVER_ENV': '{"LLM_TIMEOUT": "7200"}', # Explicit override + } + + with patch.dict(os.environ, env_vars, clear=True): + result = get_agent_server_env() + # Explicit override should win + assert result['LLM_TIMEOUT'] == '7200' + + def test_combined_auto_forward_and_explicit(self): + """Test combining auto-forwarded and explicit variables.""" + env_vars = { + 'LLM_TIMEOUT': '3600', # Auto-forwarded + 'LLM_NUM_RETRIES': '10', # Auto-forwarded + 'OH_AGENT_SERVER_ENV': '{"DEBUG": "true", "CUSTOM_VAR": "value"}', # Explicit + } + + with patch.dict(os.environ, env_vars, clear=True): + result = get_agent_server_env() + # Auto-forwarded + assert result['LLM_TIMEOUT'] == '3600' + assert result['LLM_NUM_RETRIES'] == '10' + # Explicit + assert result['DEBUG'] == 'true' + assert result['CUSTOM_VAR'] == 'value' + + def test_no_llm_vars_returns_empty_without_explicit(self): + """Test that no LLM_* vars and no explicit env returns empty dict.""" + env_vars = { + 'SOME_OTHER_VAR': 'value', + 'ANOTHER_VAR': 'another_value', + } + + with patch.dict(os.environ, env_vars, clear=True): + result = get_agent_server_env() + assert result == {} + + def test_llm_prefix_is_case_sensitive(self): + """Test that LLM_ prefix matching is case-sensitive.""" + env_vars = { + 'LLM_TIMEOUT': '3600', # Should be included + 'llm_timeout': 'lowercase', # Should NOT be included (wrong case) + 'Llm_Timeout': 'mixed', # Should NOT be included (wrong case) + } + + with patch.dict(os.environ, env_vars, clear=True): + result = get_agent_server_env() + assert 'LLM_TIMEOUT' in result + assert result['LLM_TIMEOUT'] == '3600' + # Lowercase variants should not be included + assert 'llm_timeout' not in result + assert 'Llm_Timeout' not in result + + class TestDockerSandboxSpecEnvironmentOverride: """Test environment variable override integration in Docker sandbox specs.""" @@ -476,3 +586,311 @@ class TestEnvironmentOverrideIntegration: # Should not have the old variables assert 'VAR1' not in spec_2.initial_env assert 'VAR2' not in spec_2.initial_env + + +class TestDockerSandboxServiceEnvIntegration: + """Integration tests for environment variable propagation to Docker sandbox containers. + + These tests verify that environment variables are correctly propagated through + the entire flow from the app-server environment to the agent-server container. + """ + + def test_llm_env_vars_propagated_to_container_run(self): + """Test that LLM_* env vars are included in docker container.run() environment argument.""" + from unittest.mock import patch + + # Set up environment with LLM_* variables + env_vars = { + 'LLM_TIMEOUT': '3600', + 'LLM_NUM_RETRIES': '10', + 'LLM_MODEL': 'gpt-4', + 'OTHER_VAR': 'should_not_be_forwarded', + } + + with patch.dict(os.environ, env_vars, clear=True): + # Create a sandbox spec using the actual factory to get LLM_* vars + specs = get_default_docker_sandbox_specs() + sandbox_spec = specs[0] + + # Verify the sandbox spec has the LLM_* variables + assert 'LLM_TIMEOUT' in sandbox_spec.initial_env + assert sandbox_spec.initial_env['LLM_TIMEOUT'] == '3600' + assert 'LLM_NUM_RETRIES' in sandbox_spec.initial_env + assert sandbox_spec.initial_env['LLM_NUM_RETRIES'] == '10' + assert 'LLM_MODEL' in sandbox_spec.initial_env + assert sandbox_spec.initial_env['LLM_MODEL'] == 'gpt-4' + # Non-LLM_* variables should not be included + assert 'OTHER_VAR' not in sandbox_spec.initial_env + + def test_explicit_oh_agent_server_env_overrides_llm_vars(self): + """Test that OH_AGENT_SERVER_ENV can override auto-forwarded LLM_* variables.""" + env_vars = { + 'LLM_TIMEOUT': '3600', # Auto-forwarded value + 'OH_AGENT_SERVER_ENV': '{"LLM_TIMEOUT": "7200"}', # Override value + } + + with patch.dict(os.environ, env_vars, clear=True): + specs = get_default_docker_sandbox_specs() + sandbox_spec = specs[0] + + # OH_AGENT_SERVER_ENV should take precedence + assert sandbox_spec.initial_env['LLM_TIMEOUT'] == '7200' + + def test_multiple_llm_vars_combined_with_explicit_overrides(self): + """Test complex scenario with multiple LLM_* vars and explicit overrides.""" + env_vars = { + 'LLM_TIMEOUT': '3600', + 'LLM_NUM_RETRIES': '10', + 'LLM_MODEL': 'gpt-4', + 'LLM_TEMPERATURE': '0.7', + 'OH_AGENT_SERVER_ENV': '{"LLM_MODEL": "gpt-3.5-turbo", "CUSTOM_VAR": "custom_value"}', + } + + with patch.dict(os.environ, env_vars, clear=True): + specs = get_default_docker_sandbox_specs() + sandbox_spec = specs[0] + + # Auto-forwarded LLM_* vars that weren't overridden + assert sandbox_spec.initial_env['LLM_TIMEOUT'] == '3600' + assert sandbox_spec.initial_env['LLM_NUM_RETRIES'] == '10' + assert sandbox_spec.initial_env['LLM_TEMPERATURE'] == '0.7' + + # LLM_MODEL should be overridden by OH_AGENT_SERVER_ENV + assert sandbox_spec.initial_env['LLM_MODEL'] == 'gpt-3.5-turbo' + + # Custom variable from OH_AGENT_SERVER_ENV + assert sandbox_spec.initial_env['CUSTOM_VAR'] == 'custom_value' + + def test_sandbox_spec_env_passed_to_docker_container_run(self): + """Test that sandbox spec's initial_env is passed to docker container run.""" + from unittest.mock import AsyncMock, MagicMock, patch + + import httpx + + from openhands.app_server.sandbox.docker_sandbox_service import ( + DockerSandboxService, + ExposedPort, + ) + + # Create mock docker client + mock_docker_client = MagicMock() + mock_container = MagicMock() + mock_container.name = 'oh-test-abc123' + mock_container.image.tags = ['test-image:latest'] + mock_container.attrs = { + 'Created': '2024-01-01T00:00:00Z', + 'Config': { + 'Env': ['SESSION_API_KEY=test-key'], + 'WorkingDir': '/workspace', + }, + 'NetworkSettings': {'Ports': {'8000/tcp': [{'HostPort': '32768'}]}}, + 'HostConfig': {'NetworkMode': 'bridge'}, + } + mock_container.status = 'running' + mock_docker_client.containers.run.return_value = mock_container + mock_docker_client.containers.list.return_value = [] + + # Create mock sandbox spec service + mock_spec_service = MagicMock() + + # Create sandbox spec with LLM_* environment variables + env_vars = { + 'LLM_TIMEOUT': '3600', + 'LLM_NUM_RETRIES': '10', + } + + with patch.dict(os.environ, env_vars, clear=True): + specs = get_default_docker_sandbox_specs() + sandbox_spec = specs[0] + + mock_spec_service.get_default_sandbox_spec = AsyncMock( + return_value=sandbox_spec + ) + + # Create service + service = DockerSandboxService( + sandbox_spec_service=mock_spec_service, + container_name_prefix='oh-test-', + host_port=3000, + container_url_pattern='http://localhost:{port}', + mounts=[], + exposed_ports=[ + ExposedPort( + name='AGENT_SERVER', + description='Agent server', + container_port=8000, + ) + ], + health_check_path='/health', + httpx_client=MagicMock(spec=httpx.AsyncClient), + max_num_sandboxes=5, + docker_client=mock_docker_client, + ) + + # Start sandbox + import asyncio + + asyncio.get_event_loop().run_until_complete(service.start_sandbox()) + + # Verify docker was called with environment variables including LLM_* + call_kwargs = mock_docker_client.containers.run.call_args[1] + container_env = call_kwargs['environment'] + + # LLM_* variables should be in the container environment + assert 'LLM_TIMEOUT' in container_env + assert container_env['LLM_TIMEOUT'] == '3600' + assert 'LLM_NUM_RETRIES' in container_env + assert container_env['LLM_NUM_RETRIES'] == '10' + + # Default variables should also be present + assert 'OPENVSCODE_SERVER_ROOT' in container_env + assert 'LOG_JSON' in container_env + + def test_host_network_mode_with_env_var(self): + """Test that AGENT_SERVER_USE_HOST_NETWORK affects container network mode.""" + from unittest.mock import AsyncMock, MagicMock, patch + + import httpx + + from openhands.app_server.sandbox.docker_sandbox_service import ( + DockerSandboxService, + ExposedPort, + _get_use_host_network_default, + ) + + # Test with environment variable set + with patch.dict( + os.environ, {'AGENT_SERVER_USE_HOST_NETWORK': 'true'}, clear=True + ): + assert _get_use_host_network_default() is True + + # Create mock docker client + mock_docker_client = MagicMock() + mock_container = MagicMock() + mock_container.name = 'oh-test-abc123' + mock_container.image.tags = ['test-image:latest'] + mock_container.attrs = { + 'Created': '2024-01-01T00:00:00Z', + 'Config': { + 'Env': ['SESSION_API_KEY=test-key'], + 'WorkingDir': '/workspace', + }, + 'NetworkSettings': {'Ports': {}}, + 'HostConfig': {'NetworkMode': 'host'}, + } + mock_container.status = 'running' + mock_docker_client.containers.run.return_value = mock_container + mock_docker_client.containers.list.return_value = [] + + # Create mock sandbox spec service + mock_spec_service = MagicMock() + specs = get_default_docker_sandbox_specs() + mock_spec_service.get_default_sandbox_spec = AsyncMock( + return_value=specs[0] + ) + + # Create service with host network enabled + service = DockerSandboxService( + sandbox_spec_service=mock_spec_service, + container_name_prefix='oh-test-', + host_port=3000, + container_url_pattern='http://localhost:{port}', + mounts=[], + exposed_ports=[ + ExposedPort( + name='AGENT_SERVER', + description='Agent server', + container_port=8000, + ) + ], + health_check_path='/health', + httpx_client=MagicMock(spec=httpx.AsyncClient), + max_num_sandboxes=5, + docker_client=mock_docker_client, + use_host_network=True, + ) + + # Start sandbox + import asyncio + + asyncio.get_event_loop().run_until_complete(service.start_sandbox()) + + # Verify docker was called with host network mode + call_kwargs = mock_docker_client.containers.run.call_args[1] + assert call_kwargs['network_mode'] == 'host' + # Port mappings should be None in host network mode + assert call_kwargs['ports'] is None + + def test_bridge_network_mode_without_env_var(self): + """Test that default (bridge) network mode is used when env var is not set.""" + from unittest.mock import AsyncMock, MagicMock, patch + + import httpx + + from openhands.app_server.sandbox.docker_sandbox_service import ( + DockerSandboxService, + ExposedPort, + _get_use_host_network_default, + ) + + # Test without environment variable + with patch.dict(os.environ, {}, clear=True): + assert _get_use_host_network_default() is False + + # Create mock docker client + mock_docker_client = MagicMock() + mock_container = MagicMock() + mock_container.name = 'oh-test-abc123' + mock_container.image.tags = ['test-image:latest'] + mock_container.attrs = { + 'Created': '2024-01-01T00:00:00Z', + 'Config': { + 'Env': ['SESSION_API_KEY=test-key'], + 'WorkingDir': '/workspace', + }, + 'NetworkSettings': {'Ports': {'8000/tcp': [{'HostPort': '32768'}]}}, + 'HostConfig': {'NetworkMode': 'bridge'}, + } + mock_container.status = 'running' + mock_docker_client.containers.run.return_value = mock_container + mock_docker_client.containers.list.return_value = [] + + # Create mock sandbox spec service + mock_spec_service = MagicMock() + specs = get_default_docker_sandbox_specs() + mock_spec_service.get_default_sandbox_spec = AsyncMock( + return_value=specs[0] + ) + + # Create service with bridge network (default) + service = DockerSandboxService( + sandbox_spec_service=mock_spec_service, + container_name_prefix='oh-test-', + host_port=3000, + container_url_pattern='http://localhost:{port}', + mounts=[], + exposed_ports=[ + ExposedPort( + name='AGENT_SERVER', + description='Agent server', + container_port=8000, + ) + ], + health_check_path='/health', + httpx_client=MagicMock(spec=httpx.AsyncClient), + max_num_sandboxes=5, + docker_client=mock_docker_client, + use_host_network=False, + ) + + # Start sandbox + import asyncio + + asyncio.get_event_loop().run_until_complete(service.start_sandbox()) + + # Verify docker was called with bridge network mode (network_mode=None) + call_kwargs = mock_docker_client.containers.run.call_args[1] + assert call_kwargs['network_mode'] is None + # Port mappings should be present in bridge mode + assert call_kwargs['ports'] is not None + assert 8000 in call_kwargs['ports'] diff --git a/tests/unit/app_server/test_docker_sandbox_service.py b/tests/unit/app_server/test_docker_sandbox_service.py index 23a6d51b04..f6ae716eef 100644 --- a/tests/unit/app_server/test_docker_sandbox_service.py +++ b/tests/unit/app_server/test_docker_sandbox_service.py @@ -1254,6 +1254,59 @@ class TestDockerSandboxServiceInjector: injector = DockerSandboxServiceInjector(use_host_network=True) assert injector.use_host_network is True + def test_use_host_network_from_agent_server_env_var(self): + """Test that AGENT_SERVER_USE_HOST_NETWORK env var enables host network mode.""" + import os + from unittest.mock import patch + + from openhands.app_server.sandbox.docker_sandbox_service import ( + DockerSandboxServiceInjector, + ) + + env_vars = { + 'AGENT_SERVER_USE_HOST_NETWORK': 'true', + } + + with patch.dict(os.environ, env_vars, clear=True): + injector = DockerSandboxServiceInjector() + assert injector.use_host_network is True + + def test_use_host_network_env_var_accepts_various_true_values(self): + """Test that use_host_network accepts various truthy values.""" + import os + from unittest.mock import patch + + from openhands.app_server.sandbox.docker_sandbox_service import ( + DockerSandboxServiceInjector, + ) + + for true_value in ['true', 'TRUE', 'True', '1', 'yes', 'YES', 'Yes']: + env_vars = {'AGENT_SERVER_USE_HOST_NETWORK': true_value} + with patch.dict(os.environ, env_vars, clear=True): + injector = DockerSandboxServiceInjector() + assert injector.use_host_network is True, ( + f'Failed for value: {true_value}' + ) + + def test_use_host_network_env_var_defaults_to_false(self): + """Test that unset or empty env var defaults to False.""" + import os + from unittest.mock import patch + + from openhands.app_server.sandbox.docker_sandbox_service import ( + DockerSandboxServiceInjector, + ) + + # Empty environment + with patch.dict(os.environ, {}, clear=True): + injector = DockerSandboxServiceInjector() + assert injector.use_host_network is False + + # Empty string + with patch.dict(os.environ, {'AGENT_SERVER_USE_HOST_NETWORK': ''}, clear=True): + injector = DockerSandboxServiceInjector() + assert injector.use_host_network is False + class TestDockerSandboxServiceInjectorFromEnv: """Test cases for DockerSandboxServiceInjector environment variable configuration.""" From a96760eea70fa99b1bcb73bd6a35171c596366d1 Mon Sep 17 00:00:00 2001 From: Saurya Velagapudi Date: Wed, 18 Mar 2026 17:16:43 -0700 Subject: [PATCH 73/92] fix: ensure LiteLLM user exists before generating API keys (#12667) Co-authored-by: openhands --- enterprise/storage/lite_llm_manager.py | 84 +++++- .../tests/unit/test_lite_llm_manager.py | 284 ++++++++++++++++-- 2 files changed, 331 insertions(+), 37 deletions(-) diff --git a/enterprise/storage/lite_llm_manager.py b/enterprise/storage/lite_llm_manager.py index 725b8147a3..b515b7a7d9 100644 --- a/enterprise/storage/lite_llm_manager.py +++ b/enterprise/storage/lite_llm_manager.py @@ -164,9 +164,33 @@ class LiteLlmManager: ) if create_user: - await LiteLlmManager._create_user( + user_created = await LiteLlmManager._create_user( client, keycloak_user_info.get('email'), keycloak_user_id ) + if not user_created: + logger.error( + 'create_entries_failed_user_creation', + extra={ + 'org_id': org_id, + 'user_id': keycloak_user_id, + }, + ) + return None + + # Verify user exists before proceeding with key generation + user_exists = await LiteLlmManager._user_exists( + client, keycloak_user_id + ) + if not user_exists: + logger.error( + 'create_entries_user_not_found_before_key_generation', + extra={ + 'org_id': org_id, + 'user_id': keycloak_user_id, + 'create_user_flag': create_user, + }, + ) + return None await LiteLlmManager._add_user_to_team( client, keycloak_user_id, org_id, team_budget @@ -655,15 +679,48 @@ class LiteLlmManager: ) response.raise_for_status() + @staticmethod + async def _user_exists( + client: httpx.AsyncClient, + user_id: str, + ) -> bool: + """Check if a user exists in LiteLLM. + + Returns True if the user exists, False otherwise. + """ + if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None: + return False + try: + response = await client.get( + f'{LITE_LLM_API_URL}/user/info?user_id={user_id}', + ) + if response.is_success: + user_data = response.json() + # Check that user_info exists and has the user_id + user_info = user_data.get('user_info', {}) + return user_info.get('user_id') == user_id + return False + except Exception as e: + logger.warning( + 'litellm_user_exists_check_failed', + extra={'user_id': user_id, 'error': str(e)}, + ) + return False + @staticmethod async def _create_user( client: httpx.AsyncClient, email: str | None, keycloak_user_id: str, - ): + ) -> bool: + """Create a user in LiteLLM. + + Returns True if the user was created or already exists and is verified, + False if creation failed and user does not exist. + """ if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None: logger.warning('LiteLLM API configuration not found') - return + return False response = await client.post( f'{LITE_LLM_API_URL}/user/new', json={ @@ -716,17 +773,33 @@ class LiteLlmManager: 'user_id': keycloak_user_id, }, ) - return + # Verify the user actually exists before returning success + user_exists = await LiteLlmManager._user_exists( + client, keycloak_user_id + ) + if not user_exists: + logger.error( + 'litellm_user_claimed_exists_but_not_found', + extra={ + 'user_id': keycloak_user_id, + 'status_code': response.status_code, + 'text': response.text, + }, + ) + return False + return True logger.error( 'error_creating_litellm_user', extra={ 'status_code': response.status_code, 'text': response.text, - 'user_id': [keycloak_user_id], + 'user_id': keycloak_user_id, 'email': None, }, ) + return False response.raise_for_status() + return True @staticmethod async def _get_user(client: httpx.AsyncClient, user_id: str) -> dict | None: @@ -1450,6 +1523,7 @@ class LiteLlmManager: create_team = staticmethod(with_http_client(_create_team)) get_team = staticmethod(with_http_client(_get_team)) update_team = staticmethod(with_http_client(_update_team)) + user_exists = staticmethod(with_http_client(_user_exists)) create_user = staticmethod(with_http_client(_create_user)) get_user = staticmethod(with_http_client(_get_user)) update_user = staticmethod(with_http_client(_update_user)) diff --git a/enterprise/tests/unit/test_lite_llm_manager.py b/enterprise/tests/unit/test_lite_llm_manager.py index 0cfc9fe58b..3da159421d 100644 --- a/enterprise/tests/unit/test_lite_llm_manager.py +++ b/enterprise/tests/unit/test_lite_llm_manager.py @@ -239,6 +239,16 @@ class TestLiteLlmManager: mock_404_response = MagicMock() mock_404_response.status_code = 404 mock_404_response.is_success = False + mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message='Not Found', request=MagicMock(), response=mock_404_response + ) + + # Mock user exists check response + mock_user_exists_response = MagicMock() + mock_user_exists_response.is_success = True + mock_user_exists_response.json.return_value = { + 'user_info': {'user_id': 'test-user-id'} + } mock_token_manager = MagicMock() mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock( @@ -246,12 +256,8 @@ class TestLiteLlmManager: ) mock_client = AsyncMock() - mock_client.get.return_value = mock_404_response - mock_client.get.return_value.raise_for_status.side_effect = ( - httpx.HTTPStatusError( - message='Not Found', request=MagicMock(), response=mock_404_response - ) - ) + # First GET is for _get_team (404), second GET is for _user_exists (success) + mock_client.get.side_effect = [mock_404_response, mock_user_exists_response] mock_client.post.return_value = mock_response mock_client_class = MagicMock() @@ -274,8 +280,8 @@ class TestLiteLlmManager: assert result.llm_api_key.get_secret_value() == 'test-api-key' assert result.llm_base_url == 'http://test.com' - # Verify API calls were made (get_team + 4 posts) - assert mock_client.get.call_count == 1 # get_team + # Verify API calls were made (get_team + user_exists + 4 posts) + assert mock_client.get.call_count == 2 # get_team + user_exists assert ( mock_client.post.call_count == 4 ) # create_team, add_user_to_team, delete_key_by_alias, generate_key @@ -294,13 +300,21 @@ class TestLiteLlmManager: } mock_team_response.raise_for_status = MagicMock() + # Mock user exists check response + mock_user_exists_response = MagicMock() + mock_user_exists_response.is_success = True + mock_user_exists_response.json.return_value = { + 'user_info': {'user_id': 'test-user-id'} + } + mock_token_manager = MagicMock() mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock( return_value={'email': 'test@example.com'} ) mock_client = AsyncMock() - mock_client.get.return_value = mock_team_response + # First GET is for _get_team (success), second GET is for _user_exists (success) + mock_client.get.side_effect = [mock_team_response, mock_user_exists_response] mock_client.post.return_value = mock_response mock_client_class = MagicMock() @@ -320,8 +334,8 @@ class TestLiteLlmManager: assert result is not None # Verify _get_team was called first - mock_client.get.assert_called_once() - get_call_url = mock_client.get.call_args[0][0] + assert mock_client.get.call_count == 2 # get_team + user_exists + get_call_url = mock_client.get.call_args_list[0][0][0] assert 'team/info' in get_call_url assert 'test-org-id' in get_call_url @@ -343,19 +357,25 @@ class TestLiteLlmManager: mock_404_response = MagicMock() mock_404_response.status_code = 404 mock_404_response.is_success = False + mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message='Not Found', request=MagicMock(), response=mock_404_response + ) mock_token_manager = MagicMock() mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock( return_value={'email': 'test@example.com'} ) + # Mock user exists check response + mock_user_exists_response = MagicMock() + mock_user_exists_response.is_success = True + mock_user_exists_response.json.return_value = { + 'user_info': {'user_id': 'test-user-id'} + } + mock_client = AsyncMock() - mock_client.get.return_value = mock_404_response - mock_client.get.return_value.raise_for_status.side_effect = ( - httpx.HTTPStatusError( - message='Not Found', request=MagicMock(), response=mock_404_response - ) - ) + # First GET is for _get_team (404), second GET is for _user_exists (success) + mock_client.get.side_effect = [mock_404_response, mock_user_exists_response] mock_client.post.return_value = mock_response mock_client_class = MagicMock() @@ -393,6 +413,16 @@ class TestLiteLlmManager: mock_404_response = MagicMock() mock_404_response.status_code = 404 mock_404_response.is_success = False + mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message='Not Found', request=MagicMock(), response=mock_404_response + ) + + # Mock user exists check response + mock_user_exists_response = MagicMock() + mock_user_exists_response.is_success = True + mock_user_exists_response.json.return_value = { + 'user_info': {'user_id': 'test-user-id'} + } mock_token_manager = MagicMock() mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock( @@ -400,12 +430,8 @@ class TestLiteLlmManager: ) mock_client = AsyncMock() - mock_client.get.return_value = mock_404_response - mock_client.get.return_value.raise_for_status.side_effect = ( - httpx.HTTPStatusError( - message='Not Found', request=MagicMock(), response=mock_404_response - ) - ) + # First GET is for _get_team (404), second GET is for _user_exists (success) + mock_client.get.side_effect = [mock_404_response, mock_user_exists_response] mock_client.post.return_value = mock_response mock_client_class = MagicMock() @@ -833,15 +859,16 @@ class TestLiteLlmManager: @pytest.mark.asyncio async def test_create_user_success(self, mock_http_client, mock_response): - """Test successful _create_user operation.""" + """Test successful _create_user operation returns True.""" mock_http_client.post.return_value = mock_response with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'): with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'): - await LiteLlmManager._create_user( + result = await LiteLlmManager._create_user( mock_http_client, 'test@example.com', 'test-user-id' ) + assert result is True mock_http_client.post.assert_called_once() call_args = mock_http_client.post.call_args assert 'http://test.com/user/new' in call_args[0] @@ -850,7 +877,7 @@ class TestLiteLlmManager: @pytest.mark.asyncio async def test_create_user_duplicate_email(self, mock_http_client, mock_response): - """Test _create_user with duplicate email handling.""" + """Test _create_user with duplicate email handling returns True.""" # First call fails with duplicate email error_response = MagicMock() error_response.is_success = False @@ -862,23 +889,81 @@ class TestLiteLlmManager: with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'): with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'): - await LiteLlmManager._create_user( + result = await LiteLlmManager._create_user( mock_http_client, 'test@example.com', 'test-user-id' ) + assert result is True assert mock_http_client.post.call_count == 2 # Second call should have None email second_call_args = mock_http_client.post.call_args_list[1] assert second_call_args[1]['json']['user_email'] is None + @pytest.mark.asyncio + @patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com') + @patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key') + async def test_user_exists_returns_true(self, mock_http_client): + """Test _user_exists returns True when user exists in LiteLLM.""" + # Arrange + user_response = MagicMock() + user_response.is_success = True + user_response.json.return_value = { + 'user_info': {'user_id': 'test-user-id', 'email': 'test@example.com'} + } + mock_http_client.get.return_value = user_response + + # Act + result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id') + + # Assert + assert result is True + mock_http_client.get.assert_called_once() + + @pytest.mark.asyncio + @patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com') + @patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key') + async def test_user_exists_returns_false_when_not_found(self, mock_http_client): + """Test _user_exists returns False when user not found.""" + # Arrange + user_response = MagicMock() + user_response.is_success = False + mock_http_client.get.return_value = user_response + + # Act + result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id') + + # Assert + assert result is False + + @pytest.mark.asyncio + @patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com') + @patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key') + async def test_user_exists_returns_false_on_mismatched_user_id( + self, mock_http_client + ): + """Test _user_exists returns False when returned user_id doesn't match.""" + # Arrange + user_response = MagicMock() + user_response.is_success = True + user_response.json.return_value = { + 'user_info': {'user_id': 'different-user-id'} + } + mock_http_client.get.return_value = user_response + + # Act + result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id') + + # Assert + assert result is False + @pytest.mark.asyncio @patch('storage.lite_llm_manager.logger') @patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com') @patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key') - async def test_create_user_already_exists_with_409_status_code( + async def test_create_user_already_exists_and_verified( self, mock_logger, mock_http_client ): - """Test _create_user handles 409 Conflict when user already exists.""" + """Test _create_user returns True when user already exists and is verified.""" # Arrange first_response = MagicMock() first_response.is_success = False @@ -890,14 +975,141 @@ class TestLiteLlmManager: second_response.status_code = 409 second_response.text = 'User with id test-user-id already exists' + user_exists_response = MagicMock() + user_exists_response.is_success = True + user_exists_response.json.return_value = { + 'user_info': {'user_id': 'test-user-id'} + } + mock_http_client.post.side_effect = [first_response, second_response] + mock_http_client.get.return_value = user_exists_response # Act - await LiteLlmManager._create_user( + result = await LiteLlmManager._create_user( mock_http_client, 'test@example.com', 'test-user-id' ) # Assert + assert result is True + mock_logger.warning.assert_any_call( + 'litellm_user_already_exists', + extra={'user_id': 'test-user-id'}, + ) + + @pytest.mark.asyncio + @patch('storage.lite_llm_manager.logger') + @patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com') + @patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key') + async def test_create_user_already_exists_but_not_found_returns_false( + self, mock_logger, mock_http_client + ): + """Test _create_user returns False when LiteLLM claims user exists but verification fails.""" + # Arrange + first_response = MagicMock() + first_response.is_success = False + first_response.status_code = 400 + first_response.text = 'duplicate email' + + second_response = MagicMock() + second_response.is_success = False + second_response.status_code = 409 + second_response.text = 'User with id test-user-id already exists' + + user_not_exists_response = MagicMock() + user_not_exists_response.is_success = False + + mock_http_client.post.side_effect = [first_response, second_response] + mock_http_client.get.return_value = user_not_exists_response + + # Act + result = await LiteLlmManager._create_user( + mock_http_client, 'test@example.com', 'test-user-id' + ) + + # Assert + assert result is False + mock_logger.error.assert_any_call( + 'litellm_user_claimed_exists_but_not_found', + extra={ + 'user_id': 'test-user-id', + 'status_code': 409, + 'text': 'User with id test-user-id already exists', + }, + ) + + @pytest.mark.asyncio + @patch('storage.lite_llm_manager.logger') + @patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com') + @patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key') + async def test_create_user_failure_returns_false( + self, mock_logger, mock_http_client + ): + """Test _create_user returns False when creation fails with non-'already exists' error.""" + # Arrange + first_response = MagicMock() + first_response.is_success = False + first_response.status_code = 400 + first_response.text = 'duplicate email' + + second_response = MagicMock() + second_response.is_success = False + second_response.status_code = 500 + second_response.text = 'Internal server error' + + mock_http_client.post.side_effect = [first_response, second_response] + + # Act + result = await LiteLlmManager._create_user( + mock_http_client, 'test@example.com', 'test-user-id' + ) + + # Assert + assert result is False + mock_logger.error.assert_any_call( + 'error_creating_litellm_user', + extra={ + 'status_code': 500, + 'text': 'Internal server error', + 'user_id': 'test-user-id', + 'email': None, + }, + ) + + @pytest.mark.asyncio + @patch('storage.lite_llm_manager.logger') + @patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com') + @patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key') + async def test_create_user_already_exists_with_409_status_code( + self, mock_logger, mock_http_client + ): + """Test _create_user handles 409 Conflict when user already exists and verifies.""" + # Arrange + first_response = MagicMock() + first_response.is_success = False + first_response.status_code = 400 + first_response.text = 'duplicate email' + + second_response = MagicMock() + second_response.is_success = False + second_response.status_code = 409 + second_response.text = 'User with id test-user-id already exists' + + user_exists_response = MagicMock() + user_exists_response.is_success = True + user_exists_response.json.return_value = { + 'user_info': {'user_id': 'test-user-id'} + } + + mock_http_client.post.side_effect = [first_response, second_response] + mock_http_client.get.return_value = user_exists_response + + # Act + result = await LiteLlmManager._create_user( + mock_http_client, 'test@example.com', 'test-user-id' + ) + + # Assert + assert result is True mock_logger.warning.assert_any_call( 'litellm_user_already_exists', extra={'user_id': 'test-user-id'}, @@ -910,7 +1122,7 @@ class TestLiteLlmManager: async def test_create_user_already_exists_with_400_status_code( self, mock_logger, mock_http_client ): - """Test _create_user handles 400 Bad Request when user already exists.""" + """Test _create_user handles 400 Bad Request when user already exists and verifies.""" # Arrange first_response = MagicMock() first_response.is_success = False @@ -922,14 +1134,22 @@ class TestLiteLlmManager: second_response.status_code = 400 second_response.text = 'User already exists' + user_exists_response = MagicMock() + user_exists_response.is_success = True + user_exists_response.json.return_value = { + 'user_info': {'user_id': 'test-user-id'} + } + mock_http_client.post.side_effect = [first_response, second_response] + mock_http_client.get.return_value = user_exists_response # Act - await LiteLlmManager._create_user( + result = await LiteLlmManager._create_user( mock_http_client, 'test@example.com', 'test-user-id' ) # Assert + assert result is True mock_logger.warning.assert_any_call( 'litellm_user_already_exists', extra={'user_id': 'test-user-id'}, From 8039807c3f4c8bd8eeea96c2fe6bd87399285317 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:18:29 +0700 Subject: [PATCH 74/92] fix(frontend): scope organization data queries by organization ID (org project) (#13459) --- .../analytics-consent-form-modal.test.tsx | 7 +- .../features/chat/messages.test.tsx | 6 +- .../features/home/repo-connector.test.tsx | 2 + .../settings/api-keys-manager.test.tsx | 7 +- .../features/sidebar/sidebar.test.tsx | 7 +- .../components/interactive-chat-box.test.tsx | 21 +- .../context/ws-client-provider.test.tsx | 2 + .../conversation-websocket-handler.test.tsx | 5 + .../hooks/mutation/use-save-settings.test.tsx | 7 +- .../organization-scoped-queries.test.tsx | 225 ++++++++++++++++++ frontend/__tests__/routes/accept-tos.test.tsx | 6 +- .../__tests__/routes/app-settings.test.tsx | 7 +- .../utils/check-hardcoded-strings.test.tsx | 21 +- .../settings/secrets-settings/secret-form.tsx | 10 +- .../hooks/mutation/use-add-git-providers.ts | 6 +- .../src/hooks/mutation/use-add-mcp-server.ts | 6 +- .../src/hooks/mutation/use-create-api-key.ts | 6 +- .../src/hooks/mutation/use-delete-api-key.ts | 6 +- .../hooks/mutation/use-delete-mcp-server.ts | 6 +- .../src/hooks/mutation/use-save-settings.ts | 6 +- .../hooks/mutation/use-switch-organization.ts | 5 +- .../hooks/mutation/use-update-mcp-server.ts | 6 +- frontend/src/hooks/query/use-api-keys.ts | 6 +- frontend/src/hooks/query/use-get-secrets.ts | 6 +- frontend/src/hooks/query/use-settings.ts | 13 +- frontend/src/i18n/declaration.ts | 8 + frontend/src/routes/secrets-settings.tsx | 6 +- frontend/src/routes/settings.tsx | 2 - frontend/src/routes/user-settings.tsx | 12 +- frontend/vitest.setup.ts | 9 + 30 files changed, 391 insertions(+), 51 deletions(-) create mode 100644 frontend/__tests__/hooks/query/organization-scoped-queries.test.tsx diff --git a/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx b/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx index eb7c39397c..d1e0fbf5dc 100644 --- a/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx +++ b/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx @@ -1,11 +1,16 @@ import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal"; import SettingsService from "#/api/settings-service/settings-service.api"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; describe("AnalyticsConsentFormModal", () => { + beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); + }); + it("should call saveUserSettings with consent", async () => { const user = userEvent.setup(); const onCloseMock = vi.fn(); diff --git a/frontend/__tests__/components/features/chat/messages.test.tsx b/frontend/__tests__/components/features/chat/messages.test.tsx index 577f6db5a1..194ad1ce46 100644 --- a/frontend/__tests__/components/features/chat/messages.test.tsx +++ b/frontend/__tests__/components/features/chat/messages.test.tsx @@ -10,9 +10,12 @@ import { import { OpenHandsObservation } from "#/types/core/observations"; import ConversationService from "#/api/conversation-service/conversation-service.api"; import { Conversation } from "#/api/open-hands.types"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; -vi.mock("react-router", () => ({ +vi.mock("react-router", async (importOriginal) => ({ + ...(await importOriginal()), useParams: () => ({ conversationId: "123" }), + useRevalidator: () => ({ revalidate: vi.fn() }), })); let queryClient: QueryClient; @@ -47,6 +50,7 @@ const renderMessages = ({ describe("Messages", () => { beforeEach(() => { queryClient = new QueryClient(); + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); }); const assistantMessage: AssistantMessageAction = { diff --git a/frontend/__tests__/components/features/home/repo-connector.test.tsx b/frontend/__tests__/components/features/home/repo-connector.test.tsx index 17a43c75ed..4cba3850b4 100644 --- a/frontend/__tests__/components/features/home/repo-connector.test.tsx +++ b/frontend/__tests__/components/features/home/repo-connector.test.tsx @@ -10,6 +10,7 @@ import OptionService from "#/api/option-service/option-service.api"; import { GitRepository } from "#/types/git"; import { RepoConnector } from "#/components/features/home/repo-connector"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; const renderRepoConnector = () => { const mockRepoSelection = vi.fn(); @@ -65,6 +66,7 @@ const MOCK_RESPOSITORIES: GitRepository[] = [ ]; beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); getSettingsSpy.mockResolvedValue({ ...MOCK_DEFAULT_USER_SETTINGS, diff --git a/frontend/__tests__/components/features/settings/api-keys-manager.test.tsx b/frontend/__tests__/components/features/settings/api-keys-manager.test.tsx index 6c3f9884a9..b2783c6363 100644 --- a/frontend/__tests__/components/features/settings/api-keys-manager.test.tsx +++ b/frontend/__tests__/components/features/settings/api-keys-manager.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; // Mock the react-i18next vi.mock("react-i18next", async () => { @@ -37,6 +38,10 @@ vi.mock("#/hooks/query/use-api-keys", () => ({ })); describe("ApiKeysManager", () => { + beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); + }); + const renderComponent = () => { const queryClient = new QueryClient(); return render( diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx index b83abbfeae..cf6ce1ff9b 100644 --- a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders, createAxiosNotFoundErrorObject, @@ -10,6 +10,7 @@ import SettingsService from "#/api/settings-service/settings-service.api"; import OptionService from "#/api/option-service/option-service.api"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; import { WebClientConfig } from "#/api/option-service/option.types"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; // Helper to create mock config with sensible defaults const createMockConfig = ( @@ -76,6 +77,10 @@ describe("Sidebar", () => { const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); + }); + afterEach(() => { vi.clearAllMocks(); }); diff --git a/frontend/__tests__/components/interactive-chat-box.test.tsx b/frontend/__tests__/components/interactive-chat-box.test.tsx index bafa673731..ecb6623806 100644 --- a/frontend/__tests__/components/interactive-chat-box.test.tsx +++ b/frontend/__tests__/components/interactive-chat-box.test.tsx @@ -1,26 +1,25 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router"; import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box"; import { renderWithProviders } from "../../test-utils"; import { AgentState } from "#/types/agent-state"; import { useAgentState } from "#/hooks/use-agent-state"; import { useConversationStore } from "#/stores/conversation-store"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; vi.mock("#/hooks/use-agent-state", () => ({ useAgentState: vi.fn(), })); // Mock React Router hooks -vi.mock("react-router", async () => { - const actual = await vi.importActual("react-router"); - return { - ...actual, - useNavigate: () => vi.fn(), - useParams: () => ({ conversationId: "test-conversation-id" }), - }; -}); +vi.mock("react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => vi.fn(), + useParams: () => ({ conversationId: "test-conversation-id" }), + useRevalidator: () => ({ revalidate: vi.fn() }), +})); // Mock the useActiveConversation hook vi.mock("#/hooks/query/use-active-conversation", () => ({ @@ -52,6 +51,10 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({ describe("InteractiveChatBox", () => { const onSubmitMock = vi.fn(); + beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); + }); + const mockStores = (agentState: AgentState = AgentState.INIT) => { vi.mocked(useAgentState).mockReturnValue({ curAgentState: agentState, diff --git a/frontend/__tests__/context/ws-client-provider.test.tsx b/frontend/__tests__/context/ws-client-provider.test.tsx index 55a27732fc..3e2ac11f23 100644 --- a/frontend/__tests__/context/ws-client-provider.test.tsx +++ b/frontend/__tests__/context/ws-client-provider.test.tsx @@ -7,6 +7,7 @@ import { WsClientProvider, useWsClient, } from "#/context/ws-client-provider"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; describe("Propagate error message", () => { it("should do nothing when no message was passed from server", () => { @@ -56,6 +57,7 @@ function TestComponent() { describe("WsClientProvider", () => { beforeEach(() => { vi.clearAllMocks(); + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); vi.mock("#/hooks/query/use-active-conversation", () => ({ useActiveConversation: () => { return { data: { diff --git a/frontend/__tests__/conversation-websocket-handler.test.tsx b/frontend/__tests__/conversation-websocket-handler.test.tsx index 393d6f68f0..e3de4572db 100644 --- a/frontend/__tests__/conversation-websocket-handler.test.tsx +++ b/frontend/__tests__/conversation-websocket-handler.test.tsx @@ -40,6 +40,7 @@ import { import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup"; import { useEventStore } from "#/stores/use-event-store"; import { isV1Event } from "#/types/v1/type-guards"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; // Mock useUserConversation to return V1 conversation data vi.mock("#/hooks/query/use-user-conversation", () => ({ @@ -62,6 +63,10 @@ beforeAll(() => { mswServer.listen({ onUnhandledRequest: "bypass" }); }); +beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); +}); + afterEach(() => { mswServer.resetHandlers(); // Clean up any React components diff --git a/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx b/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx index d2a7c798c4..e3216beb3c 100644 --- a/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx +++ b/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx @@ -1,10 +1,15 @@ import { renderHook, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import SettingsService from "#/api/settings-service/settings-service.api"; import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; describe("useSaveSettings", () => { + beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); + }); + it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => { const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); const { result } = renderHook(() => useSaveSettings(), { diff --git a/frontend/__tests__/hooks/query/organization-scoped-queries.test.tsx b/frontend/__tests__/hooks/query/organization-scoped-queries.test.tsx new file mode 100644 index 0000000000..a32ea3500a --- /dev/null +++ b/frontend/__tests__/hooks/query/organization-scoped-queries.test.tsx @@ -0,0 +1,225 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import { useSettings } from "#/hooks/query/use-settings"; +import { useGetSecrets } from "#/hooks/query/use-get-secrets"; +import { useApiKeys } from "#/hooks/query/use-api-keys"; +import SettingsService from "#/api/settings-service/settings-service.api"; +import { SecretsService } from "#/api/secrets-service"; +import ApiKeysClient from "#/api/api-keys"; +import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; + +vi.mock("#/hooks/query/use-config", () => ({ + useConfig: () => ({ + data: { app_mode: "saas" }, + }), +})); + +vi.mock("#/hooks/query/use-is-authed", () => ({ + useIsAuthed: () => ({ + data: true, + }), +})); + +vi.mock("#/hooks/use-is-on-intermediate-page", () => ({ + useIsOnIntermediatePage: () => false, +})); + +describe("Organization-scoped query hooks", () => { + let queryClient: QueryClient; + + const createWrapper = () => { + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + vi.clearAllMocks(); + }); + + describe("useSettings", () => { + it("should include organizationId in query key for proper cache isolation", async () => { + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); + getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS); + + const { result } = renderHook(() => useSettings(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isFetched).toBe(true)); + + // Verify the query was cached with the org-specific key + const cachedData = queryClient.getQueryData(["settings", "org-1"]); + expect(cachedData).toBeDefined(); + + // Verify no data is cached under the old key without org ID + const oldKeyData = queryClient.getQueryData(["settings"]); + expect(oldKeyData).toBeUndefined(); + }); + + it("should refetch when organization changes", async () => { + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + language: "en", + }); + + // First render with org-1 + const { result, rerender } = renderHook(() => useSettings(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isFetched).toBe(true)); + expect(getSettingsSpy).toHaveBeenCalledTimes(1); + + // Change organization + useSelectedOrganizationStore.setState({ organizationId: "org-2" }); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + language: "es", + }); + + // Rerender to pick up the new org ID + rerender(); + + await waitFor(() => { + // Should have fetched again for the new org + expect(getSettingsSpy).toHaveBeenCalledTimes(2); + }); + + // Verify both org caches exist independently + const org1Data = queryClient.getQueryData(["settings", "org-1"]); + const org2Data = queryClient.getQueryData(["settings", "org-2"]); + expect(org1Data).toBeDefined(); + expect(org2Data).toBeDefined(); + }); + }); + + describe("useGetSecrets", () => { + it("should include organizationId in query key for proper cache isolation", async () => { + const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets"); + getSecretsSpy.mockResolvedValue([]); + + const { result } = renderHook(() => useGetSecrets(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isFetched).toBe(true)); + + // Verify the query was cached with the org-specific key + const cachedData = queryClient.getQueryData(["secrets", "org-1"]); + expect(cachedData).toBeDefined(); + + // Verify no data is cached under the old key without org ID + const oldKeyData = queryClient.getQueryData(["secrets"]); + expect(oldKeyData).toBeUndefined(); + }); + + it("should fetch different data when organization changes", async () => { + const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets"); + + // Mock different secrets for different orgs + getSecretsSpy.mockResolvedValueOnce([ + { name: "SECRET_ORG_1", description: "Org 1 secret" }, + ]); + + const { result, rerender } = renderHook(() => useGetSecrets(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isFetched).toBe(true)); + expect(result.current.data).toHaveLength(1); + expect(result.current.data?.[0].name).toBe("SECRET_ORG_1"); + + // Change organization + useSelectedOrganizationStore.setState({ organizationId: "org-2" }); + getSecretsSpy.mockResolvedValueOnce([ + { name: "SECRET_ORG_2", description: "Org 2 secret" }, + ]); + + rerender(); + + await waitFor(() => { + expect(result.current.data?.[0]?.name).toBe("SECRET_ORG_2"); + }); + }); + }); + + describe("useApiKeys", () => { + it("should include organizationId in query key for proper cache isolation", async () => { + const getApiKeysSpy = vi.spyOn(ApiKeysClient, "getApiKeys"); + getApiKeysSpy.mockResolvedValue([]); + + const { result } = renderHook(() => useApiKeys(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isFetched).toBe(true)); + + // Verify the query was cached with the org-specific key + const cachedData = queryClient.getQueryData(["api-keys", "org-1"]); + expect(cachedData).toBeDefined(); + + // Verify no data is cached under the old key without org ID + const oldKeyData = queryClient.getQueryData(["api-keys"]); + expect(oldKeyData).toBeUndefined(); + }); + }); + + describe("Cache isolation between organizations", () => { + it("should maintain separate caches for each organization", async () => { + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); + + // Simulate fetching for org-1 + getSettingsSpy.mockResolvedValueOnce({ + ...MOCK_DEFAULT_USER_SETTINGS, + language: "en", + }); + + useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + const { rerender } = renderHook(() => useSettings(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(queryClient.getQueryData(["settings", "org-1"])).toBeDefined(); + }); + + // Switch to org-2 + getSettingsSpy.mockResolvedValueOnce({ + ...MOCK_DEFAULT_USER_SETTINGS, + language: "fr", + }); + + useSelectedOrganizationStore.setState({ organizationId: "org-2" }); + rerender(); + + await waitFor(() => { + expect(queryClient.getQueryData(["settings", "org-2"])).toBeDefined(); + }); + + // Switch back to org-1 - should use cached data, not refetch + useSelectedOrganizationStore.setState({ organizationId: "org-1" }); + rerender(); + + // org-1 data should still be in cache + const org1Cache = queryClient.getQueryData(["settings", "org-1"]) as any; + expect(org1Cache?.language).toBe("en"); + + // org-2 data should also still be in cache + const org2Cache = queryClient.getQueryData(["settings", "org-2"]) as any; + expect(org2Cache?.language).toBe("fr"); + }); + }); +}); diff --git a/frontend/__tests__/routes/accept-tos.test.tsx b/frontend/__tests__/routes/accept-tos.test.tsx index 7b15081485..2e1e48a1c7 100644 --- a/frontend/__tests__/routes/accept-tos.test.tsx +++ b/frontend/__tests__/routes/accept-tos.test.tsx @@ -5,9 +5,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import AcceptTOS from "#/routes/accept-tos"; import * as CaptureConsent from "#/utils/handle-capture-consent"; import { openHands } from "#/api/open-hands-axios"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; // Mock the react-router hooks -vi.mock("react-router", () => ({ +vi.mock("react-router", async (importOriginal) => ({ + ...(await importOriginal()), useNavigate: () => vi.fn(), useSearchParams: () => [ { @@ -19,6 +21,7 @@ vi.mock("react-router", () => ({ }, }, ], + useRevalidator: () => ({ revalidate: vi.fn() }), })); // Mock the axios instance @@ -54,6 +57,7 @@ const createWrapper = () => { describe("AcceptTOS", () => { beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); vi.stubGlobal("location", { href: "" }); }); diff --git a/frontend/__tests__/routes/app-settings.test.tsx b/frontend/__tests__/routes/app-settings.test.tsx index 7b42844246..a40d21d8e6 100644 --- a/frontend/__tests__/routes/app-settings.test.tsx +++ b/frontend/__tests__/routes/app-settings.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import AppSettingsScreen, { clientLoader } from "#/routes/app-settings"; @@ -8,6 +8,11 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; import { AvailableLanguages } from "#/i18n"; import * as CaptureConsent from "#/utils/handle-capture-consent"; import * as ToastHandlers from "#/utils/custom-toast-handlers"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; + +beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); +}); const renderAppSettingsScreen = () => render(, { diff --git a/frontend/__tests__/utils/check-hardcoded-strings.test.tsx b/frontend/__tests__/utils/check-hardcoded-strings.test.tsx index ff0de34962..7c0a4e592d 100644 --- a/frontend/__tests__/utils/check-hardcoded-strings.test.tsx +++ b/frontend/__tests__/utils/check-hardcoded-strings.test.tsx @@ -1,8 +1,13 @@ import { render, screen } from "@testing-library/react"; -import { test, expect, describe, vi } from "vitest"; +import { test, expect, describe, vi, beforeEach } from "vitest"; import { MemoryRouter } from "react-router"; import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box"; import { renderWithProviders } from "../../test-utils"; +import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; + +beforeEach(() => { + useSelectedOrganizationStore.setState({ organizationId: "test-org-id" }); +}); // Mock the translation function vi.mock("react-i18next", async () => { @@ -29,14 +34,12 @@ vi.mock("#/hooks/query/use-active-conversation", () => ({ })); // Mock React Router hooks -vi.mock("react-router", async () => { - const actual = await vi.importActual("react-router"); - return { - ...actual, - useNavigate: () => vi.fn(), - useParams: () => ({ conversationId: "test-conversation-id" }), - }; -}); +vi.mock("react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => vi.fn(), + useParams: () => ({ conversationId: "test-conversation-id" }), + useRevalidator: () => ({ revalidate: vi.fn() }), +})); // Mock other hooks that might be used by the component vi.mock("#/hooks/use-user-providers", () => ({ diff --git a/frontend/src/components/features/settings/secrets-settings/secret-form.tsx b/frontend/src/components/features/settings/secrets-settings/secret-form.tsx index b67e105f41..9987faced2 100644 --- a/frontend/src/components/features/settings/secrets-settings/secret-form.tsx +++ b/frontend/src/components/features/settings/secrets-settings/secret-form.tsx @@ -10,6 +10,7 @@ import { BrandButton } from "../brand-button"; import { useGetSecrets } from "#/hooks/query/use-get-secrets"; import { GetSecretsResponse } from "#/api/secrets-service.types"; import { OptionalTag } from "../optional-tag"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; interface SecretFormProps { mode: "add" | "edit"; @@ -24,6 +25,7 @@ export function SecretForm({ }: SecretFormProps) { const queryClient = useQueryClient(); const { t } = useTranslation(); + const { organizationId } = useSelectedOrganizationId(); const { data: secrets } = useGetSecrets(); const { mutate: createSecret } = useCreateSecret(); @@ -49,7 +51,9 @@ export function SecretForm({ { onSettled: onCancel, onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["secrets"] }); + await queryClient.invalidateQueries({ + queryKey: ["secrets", organizationId], + }); }, }, ); @@ -61,7 +65,7 @@ export function SecretForm({ description?: string, ) => { queryClient.setQueryData( - ["secrets"], + ["secrets", organizationId], (oldSecrets) => { if (!oldSecrets) return []; return oldSecrets.map((secret) => { @@ -79,7 +83,7 @@ export function SecretForm({ }; const revertOptimisticUpdate = () => { - queryClient.invalidateQueries({ queryKey: ["secrets"] }); + queryClient.invalidateQueries({ queryKey: ["secrets", organizationId] }); }; const handleEditSecret = ( diff --git a/frontend/src/hooks/mutation/use-add-git-providers.ts b/frontend/src/hooks/mutation/use-add-git-providers.ts index b7788b88c4..a6b7d85f8d 100644 --- a/frontend/src/hooks/mutation/use-add-git-providers.ts +++ b/frontend/src/hooks/mutation/use-add-git-providers.ts @@ -2,10 +2,12 @@ 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"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; export const useAddGitProviders = () => { const queryClient = useQueryClient(); const { trackGitProviderConnected } = useTracking(); + const { organizationId } = useSelectedOrganizationId(); return useMutation({ mutationFn: ({ @@ -25,7 +27,9 @@ export const useAddGitProviders = () => { }); } - await queryClient.invalidateQueries({ queryKey: ["settings"] }); + await queryClient.invalidateQueries({ + queryKey: ["settings", organizationId], + }); }, meta: { disableToast: true, diff --git a/frontend/src/hooks/mutation/use-add-mcp-server.ts b/frontend/src/hooks/mutation/use-add-mcp-server.ts index c9aaf4e446..bb90890f0d 100644 --- a/frontend/src/hooks/mutation/use-add-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-add-mcp-server.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSettings } from "#/hooks/query/use-settings"; import SettingsService from "#/api/settings-service/settings-service.api"; import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; type MCPServerType = "sse" | "stdio" | "shttp"; @@ -19,6 +20,7 @@ interface MCPServerConfig { export function useAddMcpServer() { const queryClient = useQueryClient(); const { data: settings } = useSettings(); + const { organizationId } = useSelectedOrganizationId(); return useMutation({ mutationFn: async (server: MCPServerConfig): Promise => { @@ -64,7 +66,9 @@ export function useAddMcpServer() { }, onSuccess: () => { // Invalidate the settings query to trigger a refetch - queryClient.invalidateQueries({ queryKey: ["settings"] }); + queryClient.invalidateQueries({ + queryKey: ["settings", organizationId], + }); }, }); } diff --git a/frontend/src/hooks/mutation/use-create-api-key.ts b/frontend/src/hooks/mutation/use-create-api-key.ts index fd3c05c975..4ab31b53df 100644 --- a/frontend/src/hooks/mutation/use-create-api-key.ts +++ b/frontend/src/hooks/mutation/use-create-api-key.ts @@ -1,16 +1,20 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import ApiKeysClient, { CreateApiKeyResponse } from "#/api/api-keys"; import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; export function useCreateApiKey() { const queryClient = useQueryClient(); + const { organizationId } = useSelectedOrganizationId(); return useMutation({ mutationFn: async (name: string): Promise => ApiKeysClient.createApiKey(name), onSuccess: () => { // Invalidate the API keys query to trigger a refetch - queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] }); + queryClient.invalidateQueries({ + queryKey: [API_KEYS_QUERY_KEY, organizationId], + }); }, }); } diff --git a/frontend/src/hooks/mutation/use-delete-api-key.ts b/frontend/src/hooks/mutation/use-delete-api-key.ts index 4f4b566fab..9932343ce6 100644 --- a/frontend/src/hooks/mutation/use-delete-api-key.ts +++ b/frontend/src/hooks/mutation/use-delete-api-key.ts @@ -1,9 +1,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import ApiKeysClient from "#/api/api-keys"; import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; export function useDeleteApiKey() { const queryClient = useQueryClient(); + const { organizationId } = useSelectedOrganizationId(); return useMutation({ mutationFn: async (id: string): Promise => { @@ -11,7 +13,9 @@ export function useDeleteApiKey() { }, onSuccess: () => { // Invalidate the API keys query to trigger a refetch - queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] }); + queryClient.invalidateQueries({ + queryKey: [API_KEYS_QUERY_KEY, organizationId], + }); }, }); } diff --git a/frontend/src/hooks/mutation/use-delete-mcp-server.ts b/frontend/src/hooks/mutation/use-delete-mcp-server.ts index 43d1b2a7cc..03cdc7759d 100644 --- a/frontend/src/hooks/mutation/use-delete-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-delete-mcp-server.ts @@ -2,10 +2,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSettings } from "#/hooks/query/use-settings"; import SettingsService from "#/api/settings-service/settings-service.api"; import { MCPConfig } from "#/types/settings"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; export function useDeleteMcpServer() { const queryClient = useQueryClient(); const { data: settings } = useSettings(); + const { organizationId } = useSelectedOrganizationId(); return useMutation({ mutationFn: async (serverId: string): Promise => { @@ -32,7 +34,9 @@ export function useDeleteMcpServer() { }, onSuccess: () => { // Invalidate the settings query to trigger a refetch - queryClient.invalidateQueries({ queryKey: ["settings"] }); + queryClient.invalidateQueries({ + queryKey: ["settings", organizationId], + }); }, }); } diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index f335fd83ec..9ccfc04ca3 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -4,6 +4,7 @@ import { DEFAULT_SETTINGS } from "#/services/settings"; import SettingsService from "#/api/settings-service/settings-service.api"; import { Settings } from "#/types/settings"; import { useSettings } from "../query/use-settings"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; const saveSettingsMutationFn = async (settings: Partial) => { const settingsToSave: Partial = { @@ -30,6 +31,7 @@ export const useSaveSettings = () => { const posthog = usePostHog(); const queryClient = useQueryClient(); const { data: currentSettings } = useSettings(); + const { organizationId } = useSelectedOrganizationId(); return useMutation({ mutationFn: async (settings: Partial) => { @@ -56,7 +58,9 @@ export const useSaveSettings = () => { await saveSettingsMutationFn(newSettings); }, onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["settings"] }); + await queryClient.invalidateQueries({ + queryKey: ["settings", organizationId], + }); }, meta: { disableToast: true, diff --git a/frontend/src/hooks/mutation/use-switch-organization.ts b/frontend/src/hooks/mutation/use-switch-organization.ts index 45fadedaf4..32e0f7b189 100644 --- a/frontend/src/hooks/mutation/use-switch-organization.ts +++ b/frontend/src/hooks/mutation/use-switch-organization.ts @@ -17,10 +17,9 @@ export const useSwitchOrganization = () => { queryClient.invalidateQueries({ queryKey: ["organizations", orgId, "me"], }); - // Update local state + // Update local state - this triggers automatic refetch for all org-scoped queries + // since their query keys include organizationId (e.g., ["settings", orgId], ["secrets", orgId]) setOrganizationId(orgId); - // Invalidate settings for the new org context - queryClient.invalidateQueries({ queryKey: ["settings"] }); // Invalidate conversations to fetch data for the new org context queryClient.invalidateQueries({ queryKey: ["user", "conversations"] }); // Remove all individual conversation queries to clear any stale/null data diff --git a/frontend/src/hooks/mutation/use-update-mcp-server.ts b/frontend/src/hooks/mutation/use-update-mcp-server.ts index 558997b500..af2a9d173f 100644 --- a/frontend/src/hooks/mutation/use-update-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-update-mcp-server.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSettings } from "#/hooks/query/use-settings"; import SettingsService from "#/api/settings-service/settings-service.api"; import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; type MCPServerType = "sse" | "stdio" | "shttp"; @@ -19,6 +20,7 @@ interface MCPServerConfig { export function useUpdateMcpServer() { const queryClient = useQueryClient(); const { data: settings } = useSettings(); + const { organizationId } = useSelectedOrganizationId(); return useMutation({ mutationFn: async ({ @@ -66,7 +68,9 @@ export function useUpdateMcpServer() { }, onSuccess: () => { // Invalidate the settings query to trigger a refetch - queryClient.invalidateQueries({ queryKey: ["settings"] }); + queryClient.invalidateQueries({ + queryKey: ["settings", organizationId], + }); }, }); } diff --git a/frontend/src/hooks/query/use-api-keys.ts b/frontend/src/hooks/query/use-api-keys.ts index 954e22ad26..2ff496253f 100644 --- a/frontend/src/hooks/query/use-api-keys.ts +++ b/frontend/src/hooks/query/use-api-keys.ts @@ -1,15 +1,17 @@ import { useQuery } from "@tanstack/react-query"; import ApiKeysClient from "#/api/api-keys"; import { useConfig } from "./use-config"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; export const API_KEYS_QUERY_KEY = "api-keys"; export function useApiKeys() { const { data: config } = useConfig(); + const { organizationId } = useSelectedOrganizationId(); return useQuery({ - queryKey: [API_KEYS_QUERY_KEY], - enabled: config?.app_mode === "saas", + queryKey: [API_KEYS_QUERY_KEY, organizationId], + enabled: config?.app_mode === "saas" && !!organizationId, queryFn: async () => { const keys = await ApiKeysClient.getApiKeys(); return Array.isArray(keys) ? keys : []; diff --git a/frontend/src/hooks/query/use-get-secrets.ts b/frontend/src/hooks/query/use-get-secrets.ts index e89df3d149..9c402e1c39 100644 --- a/frontend/src/hooks/query/use-get-secrets.ts +++ b/frontend/src/hooks/query/use-get-secrets.ts @@ -2,16 +2,18 @@ import { useQuery } from "@tanstack/react-query"; import { SecretsService } from "#/api/secrets-service"; import { useConfig } from "./use-config"; import { useIsAuthed } from "#/hooks/query/use-is-authed"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; export const useGetSecrets = () => { const { data: config } = useConfig(); const { data: isAuthed } = useIsAuthed(); + const { organizationId } = useSelectedOrganizationId(); const isOss = config?.app_mode === "oss"; return useQuery({ - queryKey: ["secrets"], + queryKey: ["secrets", organizationId], queryFn: SecretsService.getSecrets, - enabled: isOss || isAuthed, // Enable regardless of providers + enabled: isOss || (isAuthed && !!organizationId), }); }; diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index 6c6d766b69..2c18569081 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -4,6 +4,8 @@ import { DEFAULT_SETTINGS } from "#/services/settings"; import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page"; import { Settings } from "#/types/settings"; import { useIsAuthed } from "./use-is-authed"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { useConfig } from "./use-config"; const getSettingsQueryFn = async (): Promise => { const settings = await SettingsService.getSettings(); @@ -27,9 +29,13 @@ const getSettingsQueryFn = async (): Promise => { export const useSettings = () => { const isOnIntermediatePage = useIsOnIntermediatePage(); const { data: userIsAuthenticated } = useIsAuthed(); + const { organizationId } = useSelectedOrganizationId(); + const { data: config } = useConfig(); + + const isOss = config?.app_mode === "oss"; const query = useQuery({ - queryKey: ["settings"], + queryKey: ["settings", organizationId], queryFn: getSettingsQueryFn, // Only retry if the error is not a 404 because we // would want to show the modal immediately if the @@ -38,7 +44,10 @@ export const useSettings = () => { refetchOnWindowFocus: false, staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes - enabled: !isOnIntermediatePage && !!userIsAuthenticated, + enabled: + !isOnIntermediatePage && + !!userIsAuthenticated && + (isOss || !!organizationId), meta: { disableToast: true, }, diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index a66552ff3c..fe6f248cfa 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1084,6 +1084,14 @@ export enum I18nKey { CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE", CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION", CONVERSATION$LINK_COPIED = "CONVERSATION$LINK_COPIED", + ONBOARDING$STEP1_TITLE = "ONBOARDING$STEP1_TITLE", + ONBOARDING$STEP1_SUBTITLE = "ONBOARDING$STEP1_SUBTITLE", + ONBOARDING$SOFTWARE_ENGINEER = "ONBOARDING$SOFTWARE_ENGINEER", + ONBOARDING$ENGINEERING_MANAGER = "ONBOARDING$ENGINEERING_MANAGER", + ONBOARDING$CTO_FOUNDER = "ONBOARDING$CTO_FOUNDER", + ONBOARDING$PRODUCT_OPERATIONS = "ONBOARDING$PRODUCT_OPERATIONS", + ONBOARDING$STUDENT_HOBBYIST = "ONBOARDING$STUDENT_HOBBYIST", + ONBOARDING$OTHER = "ONBOARDING$OTHER", HOOKS_MODAL$TITLE = "HOOKS_MODAL$TITLE", HOOKS_MODAL$WARNING = "HOOKS_MODAL$WARNING", HOOKS_MODAL$MATCHER = "HOOKS_MODAL$MATCHER", diff --git a/frontend/src/routes/secrets-settings.tsx b/frontend/src/routes/secrets-settings.tsx index ec6a9c3a28..48cda5ecbb 100644 --- a/frontend/src/routes/secrets-settings.tsx +++ b/frontend/src/routes/secrets-settings.tsx @@ -13,12 +13,14 @@ import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal import { GetSecretsResponse } from "#/api/secrets-service.types"; import { I18nKey } from "#/i18n/declaration"; import { createPermissionGuard } from "#/utils/org/permission-guard"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; export const clientLoader = createPermissionGuard("manage_secrets"); function SecretsSettingsScreen() { const queryClient = useQueryClient(); const { t } = useTranslation(); + const { organizationId } = useSelectedOrganizationId(); const { data: secrets, isLoading: isLoadingSecrets } = useGetSecrets(); const { mutate: deleteSecret } = useDeleteSecret(); @@ -34,7 +36,7 @@ function SecretsSettingsScreen() { const deleteSecretOptimistically = (secret: string) => { queryClient.setQueryData( - ["secrets"], + ["secrets", organizationId], (oldSecrets) => { if (!oldSecrets) return []; return oldSecrets.filter((s) => s.name !== secret); @@ -43,7 +45,7 @@ function SecretsSettingsScreen() { }; const revertOptimisticUpdate = () => { - queryClient.invalidateQueries({ queryKey: ["secrets"] }); + queryClient.invalidateQueries({ queryKey: ["secrets", organizationId] }); }; const handleDeleteSecret = (secret: string) => { diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index cc1c3563c6..9082bea730 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -30,7 +30,6 @@ const SAAS_ONLY_PATHS = [ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { const url = new URL(request.url); const { pathname } = url; - console.log("clientLoader", { pathname }); // Step 1: Get config first (needed for all checks, no user data required) let config = queryClient.getQueryData(["web-client-config"]); @@ -51,7 +50,6 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => { // This handles hide_llm_settings, hide_users_page, hide_billing_page, hide_integrations_page if (isSettingsPageHidden(pathname, featureFlags)) { const fallbackPath = getFirstAvailablePath(isSaas, featureFlags); - console.log("fallbackPath", fallbackPath); if (fallbackPath && fallbackPath !== pathname) { return redirect(fallbackPath); } diff --git a/frontend/src/routes/user-settings.tsx b/frontend/src/routes/user-settings.tsx index 3e40d104a8..6fd4372ecf 100644 --- a/frontend/src/routes/user-settings.tsx +++ b/frontend/src/routes/user-settings.tsx @@ -5,6 +5,7 @@ import { useSettings } from "#/hooks/query/use-settings"; import { openHands } from "#/api/open-hands-axios"; import { displaySuccessToast } from "#/utils/custom-toast-handlers"; import { useEmailVerification } from "#/hooks/use-email-verification"; +import { useSelectedOrganizationId } from "#/context/use-selected-organization"; // Email validation regex pattern const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; @@ -113,6 +114,7 @@ function VerificationAlert() { function UserSettingsScreen() { const { t } = useTranslation(); const { data: settings, isLoading, refetch } = useSettings(); + const { organizationId } = useSelectedOrganizationId(); const [email, setEmail] = useState(""); const [originalEmail, setOriginalEmail] = useState(""); const [isSaving, setIsSaving] = useState(false); @@ -144,7 +146,9 @@ function UserSettingsScreen() { // Display toast notification instead of setting state displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY")); setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ["settings"] }); + queryClient.invalidateQueries({ + queryKey: ["settings", organizationId], + }); }, 2000); } @@ -162,7 +166,7 @@ function UserSettingsScreen() { pollingIntervalRef.current = null; } }; - }, [settings?.email_verified, refetch, queryClient, t]); + }, [settings?.email_verified, refetch, queryClient, t, organizationId]); const handleEmailChange = (e: React.ChangeEvent) => { const newEmail = e.target.value; @@ -178,7 +182,9 @@ function UserSettingsScreen() { setOriginalEmail(email); // Display toast notification instead of setting state displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY")); - queryClient.invalidateQueries({ queryKey: ["settings"] }); + queryClient.invalidateQueries({ + queryKey: ["settings", organizationId], + }); } catch (error) { // eslint-disable-next-line no-console console.error(t("SETTINGS$FAILED_TO_SAVE_EMAIL"), error); diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index c43fa03553..b96506baba 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -36,6 +36,15 @@ vi.mock("#/hooks/use-is-on-intermediate-page", () => ({ useIsOnIntermediatePage: () => false, })); +// Mock useRevalidator from react-router to allow direct store manipulation +// in tests instead of mocking useSelectedOrganizationId hook +vi.mock("react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useRevalidator: () => ({ + revalidate: vi.fn(), + }), +})); + // Import the Zustand mock to enable automatic store resets vi.mock("zustand"); From e02dbb89749c6a4874ea6ca5cdb78037699f7ecd Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:09:37 +0700 Subject: [PATCH 75/92] fix(backend): validate API key org_id during authorization to prevent cross-org access (org project) (#13468) --- enterprise/server/auth/authorization.py | 38 ++- enterprise/server/auth/saas_user_auth.py | 11 +- enterprise/storage/api_key_store.py | 11 +- .../storage/saas_conversation_validator.py | 4 +- enterprise/tests/unit/test_api_key_store.py | 48 +++- enterprise/tests/unit/test_authorization.py | 271 +++++++++++++++++- enterprise/tests/unit/test_saas_user_auth.py | 3 +- 7 files changed, 354 insertions(+), 32 deletions(-) diff --git a/enterprise/server/auth/authorization.py b/enterprise/server/auth/authorization.py index c8d72021c6..203f74f112 100644 --- a/enterprise/server/auth/authorization.py +++ b/enterprise/server/auth/authorization.py @@ -35,7 +35,7 @@ Usage: from enum import Enum from uuid import UUID -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from storage.org_member_store import OrgMemberStore from storage.role import Role from storage.role_store import RoleStore @@ -214,6 +214,19 @@ def has_permission(user_role: Role, permission: Permission) -> bool: return permission in permissions +async def get_api_key_org_id_from_request(request: Request) -> UUID | None: + """Get the org_id bound to the API key used for authentication. + + Returns None if: + - Not authenticated via API key (cookie auth) + - API key is a legacy key without org binding + """ + user_auth = getattr(request.state, 'user_auth', None) + if user_auth and hasattr(user_auth, 'get_api_key_org_id'): + return user_auth.get_api_key_org_id() + return None + + def require_permission(permission: Permission): """ Factory function that creates a dependency to require a specific permission. @@ -221,8 +234,9 @@ def require_permission(permission: Permission): This creates a FastAPI dependency that: 1. Extracts org_id from the path parameter 2. Gets the authenticated user_id - 3. Checks if the user has the required permission in the organization - 4. Returns the user_id if authorized, raises HTTPException otherwise + 3. Validates API key org binding (if using API key auth) + 4. Checks if the user has the required permission in the organization + 5. Returns the user_id if authorized, raises HTTPException otherwise Usage: @router.get('/{org_id}/settings') @@ -240,6 +254,7 @@ def require_permission(permission: Permission): """ async def permission_checker( + request: Request, org_id: UUID | None = None, user_id: str | None = Depends(get_user_id), ) -> str: @@ -249,6 +264,23 @@ def require_permission(permission: Permission): detail='User not authenticated', ) + # Validate API key organization binding + api_key_org_id = await get_api_key_org_id_from_request(request) + if api_key_org_id is not None and org_id is not None: + if api_key_org_id != org_id: + logger.warning( + 'API key organization mismatch', + extra={ + 'user_id': user_id, + 'api_key_org_id': str(api_key_org_id), + 'target_org_id': str(org_id), + }, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='API key is not authorized for this organization', + ) + user_role = await get_user_org_role(user_id, org_id) if not user_role: diff --git a/enterprise/server/auth/saas_user_auth.py b/enterprise/server/auth/saas_user_auth.py index 6c8aefea7a..15f50cc40b 100644 --- a/enterprise/server/auth/saas_user_auth.py +++ b/enterprise/server/auth/saas_user_auth.py @@ -61,10 +61,19 @@ class SaasUserAuth(UserAuth): accepted_tos: bool | None = None auth_type: AuthType = AuthType.COOKIE # API key context fields - populated when authenticated via API key - api_key_org_id: UUID | None = None + api_key_org_id: UUID | None = None # Org bound to the API key used for auth api_key_id: int | None = None api_key_name: str | None = None + def get_api_key_org_id(self) -> UUID | None: + """Get the organization ID bound to the API key used for authentication. + + Returns: + The org_id if authenticated via API key with org binding, None otherwise + (cookie auth or legacy API keys without org binding). + """ + return self.api_key_org_id + async def get_user_id(self) -> str | None: return self.user_id diff --git a/enterprise/storage/api_key_store.py b/enterprise/storage/api_key_store.py index ecbb375592..30e5b242e8 100644 --- a/enterprise/storage/api_key_store.py +++ b/enterprise/storage/api_key_store.py @@ -16,10 +16,10 @@ from openhands.core.logger import openhands_logger as logger @dataclass class ApiKeyValidationResult: - """Result of API key validation containing user and org context.""" + """Result of API key validation containing user and organization info.""" user_id: str - org_id: UUID | None + org_id: UUID | None # None for legacy API keys without org binding key_id: int key_name: str | None @@ -195,7 +195,12 @@ class ApiKeyStore: return api_key async def validate_api_key(self, api_key: str) -> ApiKeyValidationResult | None: - """Validate an API key and return the associated user_id and org_id if valid.""" + """Validate an API key and return the associated user_id and org_id if valid. + + Returns: + ApiKeyValidationResult if the key is valid, None otherwise. + The org_id may be None for legacy API keys that weren't bound to an organization. + """ now = datetime.now(UTC) async with a_session_maker() as session: diff --git a/enterprise/storage/saas_conversation_validator.py b/enterprise/storage/saas_conversation_validator.py index 51a5302dfc..6493fdd602 100644 --- a/enterprise/storage/saas_conversation_validator.py +++ b/enterprise/storage/saas_conversation_validator.py @@ -15,13 +15,13 @@ class SaasConversationValidator(ConversationValidator): async def _validate_api_key(self, api_key: str) -> str | None: """ - Validate an API key and return the user_id and github_user_id if valid. + Validate an API key and return the user_id if valid. Args: api_key: The API key to validate Returns: - A tuple of (user_id, github_user_id) if the API key is valid, None otherwise + The user_id if the API key is valid, None otherwise """ try: token_manager = TokenManager() diff --git a/enterprise/tests/unit/test_api_key_store.py b/enterprise/tests/unit/test_api_key_store.py index baffe5893c..c57f63f2ae 100644 --- a/enterprise/tests/unit/test_api_key_store.py +++ b/enterprise/tests/unit/test_api_key_store.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from sqlalchemy import select from storage.api_key import ApiKey -from storage.api_key_store import ApiKeyStore +from storage.api_key_store import ApiKeyStore, ApiKeyValidationResult @pytest.fixture @@ -110,8 +110,8 @@ async def test_create_api_key( @pytest.mark.asyncio async def test_validate_api_key_valid(api_key_store, async_session_maker): - """Test validating a valid API key.""" - # Setup - create an API key in the database + """Test validating a valid API key returns user_id and org_id.""" + # Arrange user_id = str(uuid.uuid4()) org_id = uuid.uuid4() api_key_value = 'test-api-key' @@ -128,11 +128,12 @@ async def test_validate_api_key_valid(api_key_store, async_session_maker): await session.commit() key_id = key_record.id - # Execute - patch a_session_maker to use test's async session maker + # Act with patch('storage.api_key_store.a_session_maker', async_session_maker): result = await api_key_store.validate_api_key(api_key_value) - # Verify - result is now ApiKeyValidationResult + # Assert + assert isinstance(result, ApiKeyValidationResult) assert result is not None assert result.user_id == user_id assert result.org_id == org_id @@ -202,7 +203,7 @@ async def test_validate_api_key_valid_timezone_naive( api_key_store, async_session_maker ): """Test validating a valid API key with timezone-naive datetime from database.""" - # Setup - create a valid API key with timezone-naive datetime (future date) + # Arrange user_id = str(uuid.uuid4()) org_id = uuid.uuid4() api_key_value = 'test-valid-naive-key' @@ -219,13 +220,44 @@ async def test_validate_api_key_valid_timezone_naive( session.add(key_record) await session.commit() - # Execute - patch a_session_maker to use test's async session maker + # Act with patch('storage.api_key_store.a_session_maker', async_session_maker): result = await api_key_store.validate_api_key(api_key_value) - # Verify - result is now ApiKeyValidationResult + # Assert + assert isinstance(result, ApiKeyValidationResult) + assert result.user_id == user_id + assert result.org_id == org_id + + +@pytest.mark.asyncio +async def test_validate_api_key_legacy_without_org_id( + api_key_store, async_session_maker +): + """Test validating a legacy API key without org_id returns None for org_id.""" + # Arrange + user_id = str(uuid.uuid4()) + api_key_value = 'test-legacy-key-no-org' + + async with async_session_maker() as session: + key_record = ApiKey( + key=api_key_value, + user_id=user_id, + org_id=None, # Legacy key without org binding + name='Legacy Key', + ) + session.add(key_record) + await session.commit() + + # Act + with patch('storage.api_key_store.a_session_maker', async_session_maker): + result = await api_key_store.validate_api_key(api_key_value) + + # Assert + assert isinstance(result, ApiKeyValidationResult) assert result is not None assert result.user_id == user_id + assert result.org_id is None @pytest.mark.asyncio diff --git a/enterprise/tests/unit/test_authorization.py b/enterprise/tests/unit/test_authorization.py index c751e6454a..a4051b4824 100644 --- a/enterprise/tests/unit/test_authorization.py +++ b/enterprise/tests/unit/test_authorization.py @@ -13,6 +13,7 @@ from server.auth.authorization import ( ROLE_PERMISSIONS, Permission, RoleName, + get_api_key_org_id_from_request, get_role_permissions, get_user_org_role, has_permission, @@ -444,6 +445,15 @@ class TestGetUserOrgRole: # ============================================================================= +def _create_mock_request(api_key_org_id=None): + """Helper to create a mock request with optional api_key_org_id.""" + mock_request = MagicMock() + mock_user_auth = MagicMock() + mock_user_auth.get_api_key_org_id.return_value = api_key_org_id + mock_request.state.user_auth = mock_user_auth + return mock_request + + class TestRequirePermission: """Tests for require_permission dependency factory.""" @@ -456,6 +466,7 @@ class TestRequirePermission: """ user_id = str(uuid4()) org_id = uuid4() + mock_request = _create_mock_request() mock_role = MagicMock() mock_role.name = 'admin' @@ -465,7 +476,9 @@ class TestRequirePermission: AsyncMock(return_value=mock_role), ): permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS) - result = await permission_checker(org_id=org_id, user_id=user_id) + result = await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) assert result == user_id @pytest.mark.asyncio @@ -476,10 +489,11 @@ class TestRequirePermission: THEN: 401 Unauthorized is raised """ org_id = uuid4() + mock_request = _create_mock_request() permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS) with pytest.raises(HTTPException) as exc_info: - await permission_checker(org_id=org_id, user_id=None) + await permission_checker(request=mock_request, org_id=org_id, user_id=None) assert exc_info.value.status_code == 401 assert 'not authenticated' in exc_info.value.detail.lower() @@ -493,6 +507,7 @@ class TestRequirePermission: """ user_id = str(uuid4()) org_id = uuid4() + mock_request = _create_mock_request() with patch( 'server.auth.authorization.get_user_org_role', @@ -500,7 +515,9 @@ class TestRequirePermission: ): permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS) with pytest.raises(HTTPException) as exc_info: - await permission_checker(org_id=org_id, user_id=user_id) + await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) assert exc_info.value.status_code == 403 assert 'not a member' in exc_info.value.detail.lower() @@ -514,6 +531,7 @@ class TestRequirePermission: """ user_id = str(uuid4()) org_id = uuid4() + mock_request = _create_mock_request() mock_role = MagicMock() mock_role.name = 'member' @@ -524,7 +542,9 @@ class TestRequirePermission: ): permission_checker = require_permission(Permission.DELETE_ORGANIZATION) with pytest.raises(HTTPException) as exc_info: - await permission_checker(org_id=org_id, user_id=user_id) + await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) assert exc_info.value.status_code == 403 assert 'delete_organization' in exc_info.value.detail.lower() @@ -538,6 +558,7 @@ class TestRequirePermission: """ user_id = str(uuid4()) org_id = uuid4() + mock_request = _create_mock_request() mock_role = MagicMock() mock_role.name = 'owner' @@ -547,7 +568,9 @@ class TestRequirePermission: AsyncMock(return_value=mock_role), ): permission_checker = require_permission(Permission.DELETE_ORGANIZATION) - result = await permission_checker(org_id=org_id, user_id=user_id) + result = await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) assert result == user_id @pytest.mark.asyncio @@ -559,6 +582,7 @@ class TestRequirePermission: """ user_id = str(uuid4()) org_id = uuid4() + mock_request = _create_mock_request() mock_role = MagicMock() mock_role.name = 'admin' @@ -569,7 +593,9 @@ class TestRequirePermission: ): permission_checker = require_permission(Permission.DELETE_ORGANIZATION) with pytest.raises(HTTPException) as exc_info: - await permission_checker(org_id=org_id, user_id=user_id) + await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) assert exc_info.value.status_code == 403 @@ -582,6 +608,7 @@ class TestRequirePermission: """ user_id = str(uuid4()) org_id = uuid4() + mock_request = _create_mock_request() mock_role = MagicMock() mock_role.name = 'member' @@ -595,7 +622,9 @@ class TestRequirePermission: ): permission_checker = require_permission(Permission.DELETE_ORGANIZATION) with pytest.raises(HTTPException): - await permission_checker(org_id=org_id, user_id=user_id) + await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) mock_logger.warning.assert_called() call_args = mock_logger.warning.call_args @@ -611,6 +640,7 @@ class TestRequirePermission: THEN: User ID is returned """ user_id = str(uuid4()) + mock_request = _create_mock_request() mock_role = MagicMock() mock_role.name = 'admin' @@ -620,7 +650,9 @@ class TestRequirePermission: AsyncMock(return_value=mock_role), ) as mock_get_role: permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS) - result = await permission_checker(org_id=None, user_id=user_id) + result = await permission_checker( + request=mock_request, org_id=None, user_id=user_id + ) assert result == user_id mock_get_role.assert_called_once_with(user_id, None) @@ -632,6 +664,7 @@ class TestRequirePermission: THEN: HTTPException with 403 status is raised """ user_id = str(uuid4()) + mock_request = _create_mock_request() with patch( 'server.auth.authorization.get_user_org_role', @@ -639,7 +672,9 @@ class TestRequirePermission: ): permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS) with pytest.raises(HTTPException) as exc_info: - await permission_checker(org_id=None, user_id=user_id) + await permission_checker( + request=mock_request, org_id=None, user_id=user_id + ) assert exc_info.value.status_code == 403 assert 'not a member' in exc_info.value.detail @@ -662,6 +697,7 @@ class TestPermissionScenarios: """ user_id = str(uuid4()) org_id = uuid4() + mock_request = _create_mock_request() mock_role = MagicMock() mock_role.name = 'member' @@ -671,7 +707,9 @@ class TestPermissionScenarios: AsyncMock(return_value=mock_role), ): permission_checker = require_permission(Permission.MANAGE_SECRETS) - result = await permission_checker(org_id=org_id, user_id=user_id) + result = await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) assert result == user_id @pytest.mark.asyncio @@ -683,6 +721,7 @@ class TestPermissionScenarios: """ user_id = str(uuid4()) org_id = uuid4() + mock_request = _create_mock_request() mock_role = MagicMock() mock_role.name = 'member' @@ -695,7 +734,9 @@ class TestPermissionScenarios: Permission.INVITE_USER_TO_ORGANIZATION ) with pytest.raises(HTTPException) as exc_info: - await permission_checker(org_id=org_id, user_id=user_id) + await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) assert exc_info.value.status_code == 403 @@ -708,6 +749,7 @@ class TestPermissionScenarios: """ user_id = str(uuid4()) org_id = uuid4() + mock_request = _create_mock_request() mock_role = MagicMock() mock_role.name = 'admin' @@ -719,7 +761,9 @@ class TestPermissionScenarios: permission_checker = require_permission( Permission.INVITE_USER_TO_ORGANIZATION ) - result = await permission_checker(org_id=org_id, user_id=user_id) + result = await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) assert result == user_id @pytest.mark.asyncio @@ -731,6 +775,7 @@ class TestPermissionScenarios: """ user_id = str(uuid4()) org_id = uuid4() + mock_request = _create_mock_request() mock_role = MagicMock() mock_role.name = 'admin' @@ -741,7 +786,9 @@ class TestPermissionScenarios: ): permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER) with pytest.raises(HTTPException) as exc_info: - await permission_checker(org_id=org_id, user_id=user_id) + await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) assert exc_info.value.status_code == 403 @@ -754,6 +801,7 @@ class TestPermissionScenarios: """ user_id = str(uuid4()) org_id = uuid4() + mock_request = _create_mock_request() mock_role = MagicMock() mock_role.name = 'owner' @@ -763,5 +811,200 @@ class TestPermissionScenarios: AsyncMock(return_value=mock_role), ): permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER) - result = await permission_checker(org_id=org_id, user_id=user_id) + result = await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) assert result == user_id + + +# ============================================================================= +# Tests for API key organization validation +# ============================================================================= + + +class TestApiKeyOrgValidation: + """Tests for API key organization binding validation in require_permission.""" + + @pytest.mark.asyncio + async def test_allows_access_when_api_key_org_matches_target_org(self): + """ + GIVEN: API key with org_id that matches the target org_id in the request + WHEN: Permission checker is called + THEN: User ID is returned (access allowed) + """ + # Arrange + user_id = str(uuid4()) + org_id = uuid4() + mock_request = _create_mock_request(api_key_org_id=org_id) + + mock_role = MagicMock() + mock_role.name = 'admin' + + # Act & Assert + with patch( + 'server.auth.authorization.get_user_org_role', + AsyncMock(return_value=mock_role), + ): + permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS) + result = await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) + assert result == user_id + + @pytest.mark.asyncio + async def test_denies_access_when_api_key_org_mismatches_target_org(self): + """ + GIVEN: API key created for Org A, but user tries to access Org B + WHEN: Permission checker is called + THEN: 403 Forbidden is raised with org mismatch message + """ + # Arrange + user_id = str(uuid4()) + api_key_org_id = uuid4() # Org A - where API key was created + target_org_id = uuid4() # Org B - where user is trying to access + mock_request = _create_mock_request(api_key_org_id=api_key_org_id) + + # Act & Assert + permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS) + with pytest.raises(HTTPException) as exc_info: + await permission_checker( + request=mock_request, org_id=target_org_id, user_id=user_id + ) + + assert exc_info.value.status_code == 403 + assert ( + 'API key is not authorized for this organization' in exc_info.value.detail + ) + + @pytest.mark.asyncio + async def test_allows_access_for_legacy_api_key_without_org_binding(self): + """ + GIVEN: Legacy API key without org_id binding (org_id is None) + WHEN: Permission checker is called + THEN: Falls through to normal permission check (backward compatible) + """ + # Arrange + user_id = str(uuid4()) + org_id = uuid4() + mock_request = _create_mock_request(api_key_org_id=None) + + mock_role = MagicMock() + mock_role.name = 'admin' + + # Act & Assert + with patch( + 'server.auth.authorization.get_user_org_role', + AsyncMock(return_value=mock_role), + ): + permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS) + result = await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) + assert result == user_id + + @pytest.mark.asyncio + async def test_allows_access_for_cookie_auth_without_api_key_org_id(self): + """ + GIVEN: Cookie-based authentication (no api_key_org_id in user_auth) + WHEN: Permission checker is called + THEN: Falls through to normal permission check + """ + # Arrange + user_id = str(uuid4()) + org_id = uuid4() + mock_request = _create_mock_request(api_key_org_id=None) + + mock_role = MagicMock() + mock_role.name = 'admin' + + # Act & Assert + with patch( + 'server.auth.authorization.get_user_org_role', + AsyncMock(return_value=mock_role), + ): + permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS) + result = await permission_checker( + request=mock_request, org_id=org_id, user_id=user_id + ) + assert result == user_id + + @pytest.mark.asyncio + async def test_logs_warning_on_api_key_org_mismatch(self): + """ + GIVEN: API key org_id doesn't match target org_id + WHEN: Permission checker is called + THEN: Warning is logged with org mismatch details + """ + # Arrange + user_id = str(uuid4()) + api_key_org_id = uuid4() + target_org_id = uuid4() + mock_request = _create_mock_request(api_key_org_id=api_key_org_id) + + # Act & Assert + with patch('server.auth.authorization.logger') as mock_logger: + permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS) + with pytest.raises(HTTPException): + await permission_checker( + request=mock_request, org_id=target_org_id, user_id=user_id + ) + + mock_logger.warning.assert_called() + call_args = mock_logger.warning.call_args + assert call_args[1]['extra']['user_id'] == user_id + assert call_args[1]['extra']['api_key_org_id'] == str(api_key_org_id) + assert call_args[1]['extra']['target_org_id'] == str(target_org_id) + + +class TestGetApiKeyOrgIdFromRequest: + """Tests for get_api_key_org_id_from_request helper function.""" + + @pytest.mark.asyncio + async def test_returns_org_id_when_user_auth_has_api_key_org_id(self): + """ + GIVEN: Request with user_auth that has api_key_org_id + WHEN: get_api_key_org_id_from_request is called + THEN: Returns the api_key_org_id + """ + # Arrange + org_id = uuid4() + mock_request = _create_mock_request(api_key_org_id=org_id) + + # Act + result = await get_api_key_org_id_from_request(mock_request) + + # Assert + assert result == org_id + + @pytest.mark.asyncio + async def test_returns_none_when_user_auth_has_no_api_key_org_id(self): + """ + GIVEN: Request with user_auth that has no api_key_org_id (cookie auth) + WHEN: get_api_key_org_id_from_request is called + THEN: Returns None + """ + # Arrange + mock_request = _create_mock_request(api_key_org_id=None) + + # Act + result = await get_api_key_org_id_from_request(mock_request) + + # Assert + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_when_no_user_auth_in_request(self): + """ + GIVEN: Request without user_auth in state + WHEN: get_api_key_org_id_from_request is called + THEN: Returns None + """ + # Arrange + mock_request = MagicMock() + mock_request.state.user_auth = None + + # Act + result = await get_api_key_org_id_from_request(mock_request) + + # Assert + assert result is None diff --git a/enterprise/tests/unit/test_saas_user_auth.py b/enterprise/tests/unit/test_saas_user_auth.py index 2fb1b68445..726702f310 100644 --- a/enterprise/tests/unit/test_saas_user_auth.py +++ b/enterprise/tests/unit/test_saas_user_auth.py @@ -459,7 +459,8 @@ async def test_get_instance_no_auth(mock_request): @pytest.mark.asyncio async def test_saas_user_auth_from_bearer_success(): - """Test successful authentication from bearer token.""" + """Test successful authentication from bearer token sets user_id and api_key_org_id.""" + # Arrange mock_request = MagicMock() mock_request.headers = {'Authorization': 'Bearer test_api_key'} From 3a9f00aa3714cd67398aa8ac7ccfe1b966134073 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Thu, 19 Mar 2026 14:46:56 +0100 Subject: [PATCH 76/92] Keep VSCode accessible when agent errors (#13492) Co-authored-by: openhands --- .../hooks/use-runtime-is-ready.test.tsx | 64 ++++++++++++++++++ frontend/__tests__/routes/vscode-tab.test.tsx | 65 +++++++++++++++++++ .../vscode-tooltip-content.tsx | 5 +- .../src/hooks/query/use-unified-vscode-url.ts | 2 +- frontend/src/hooks/use-runtime-is-ready.ts | 20 ++++-- frontend/src/routes/vscode-tab.tsx | 8 +-- frontend/src/types/agent-state.tsx | 5 +- 7 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 frontend/__tests__/hooks/use-runtime-is-ready.test.tsx create mode 100644 frontend/__tests__/routes/vscode-tab.test.tsx diff --git a/frontend/__tests__/hooks/use-runtime-is-ready.test.tsx b/frontend/__tests__/hooks/use-runtime-is-ready.test.tsx new file mode 100644 index 0000000000..86bc8d82f7 --- /dev/null +++ b/frontend/__tests__/hooks/use-runtime-is-ready.test.tsx @@ -0,0 +1,64 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Conversation } from "#/api/open-hands.types"; +import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { AgentState } from "#/types/agent-state"; + +vi.mock("#/hooks/use-agent-state"); +vi.mock("#/hooks/query/use-active-conversation"); + +function asMockReturnValue(value: Partial): T { + return value as T; +} + +function makeConversation(): Conversation { + return { + conversation_id: "conv-123", + title: "Test Conversation", + selected_repository: null, + selected_branch: null, + git_provider: null, + last_updated_at: new Date().toISOString(), + created_at: new Date().toISOString(), + status: "RUNNING", + runtime_status: null, + url: null, + session_api_key: null, + }; +} + +describe("useRuntimeIsReady", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(useActiveConversation).mockReturnValue( + asMockReturnValue>({ + data: makeConversation(), + }), + ); + }); + + it("treats agent errors as not ready by default", () => { + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: AgentState.ERROR, + }); + + const { result } = renderHook(() => useRuntimeIsReady()); + + expect(result.current).toBe(false); + }); + + it("allows runtime-backed tabs to stay ready when the agent errors", () => { + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: AgentState.ERROR, + }); + + const { result } = renderHook(() => + useRuntimeIsReady({ allowAgentError: true }), + ); + + expect(result.current).toBe(true); + }); +}); diff --git a/frontend/__tests__/routes/vscode-tab.test.tsx b/frontend/__tests__/routes/vscode-tab.test.tsx new file mode 100644 index 0000000000..8c84678603 --- /dev/null +++ b/frontend/__tests__/routes/vscode-tab.test.tsx @@ -0,0 +1,65 @@ +import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "test-utils"; +import VSCodeTab from "#/routes/vscode-tab"; +import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { AgentState } from "#/types/agent-state"; + +vi.mock("#/hooks/query/use-unified-vscode-url"); +vi.mock("#/hooks/use-agent-state"); +vi.mock("#/utils/feature-flags", () => ({ + VSCODE_IN_NEW_TAB: () => false, +})); + +function mockVSCodeUrlHook( + value: Partial>, +) { + vi.mocked(useUnifiedVSCodeUrl).mockReturnValue({ + data: { url: "http://localhost:3000/vscode", error: null }, + error: null, + isLoading: false, + isError: false, + isSuccess: true, + status: "success", + refetch: vi.fn(), + ...value, + } as ReturnType); +} + +describe("VSCodeTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("keeps VSCode accessible when the agent is in an error state", () => { + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: AgentState.ERROR, + }); + mockVSCodeUrlHook({}); + + renderWithProviders(); + + expect( + screen.queryByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"), + ).not.toBeInTheDocument(); + expect(screen.getByTitle("VSCODE$TITLE")).toHaveAttribute( + "src", + "http://localhost:3000/vscode", + ); + }); + + it("still waits while the runtime is starting", () => { + vi.mocked(useAgentState).mockReturnValue({ + curAgentState: AgentState.LOADING, + }); + mockVSCodeUrlHook({}); + + renderWithProviders(); + + expect( + screen.getByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"), + ).toBeInTheDocument(); + expect(screen.queryByTitle("VSCODE$TITLE")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx b/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx index 07509ab19d..08e7879fad 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/vscode-tooltip-content.tsx @@ -1,14 +1,15 @@ import { FaExternalLinkAlt } from "react-icons/fa"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; -import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; import { useAgentState } from "#/hooks/use-agent-state"; import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url"; +import { RUNTIME_STARTING_STATES } from "#/types/agent-state"; export function VSCodeTooltipContent() { const { curAgentState } = useAgentState(); const { t } = useTranslation(); const { data, refetch } = useUnifiedVSCodeUrl(); + const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState); const handleVSCodeClick = async (e: React.MouseEvent) => { e.preventDefault(); @@ -29,7 +30,7 @@ export function VSCodeTooltipContent() { return (
{t(I18nKey.COMMON$CODE)} - {!RUNTIME_INACTIVE_STATES.includes(curAgentState) ? ( + {!isRuntimeStarting ? ( { const { t } = useTranslation(); const { conversationId } = useConversationId(); const { data: conversation } = useActiveConversation(); - const runtimeIsReady = useRuntimeIsReady(); + const runtimeIsReady = useRuntimeIsReady({ allowAgentError: true }); const isV1Conversation = conversation?.conversation_version === "V1"; diff --git a/frontend/src/hooks/use-runtime-is-ready.ts b/frontend/src/hooks/use-runtime-is-ready.ts index 914b3624c4..e09af98872 100644 --- a/frontend/src/hooks/use-runtime-is-ready.ts +++ b/frontend/src/hooks/use-runtime-is-ready.ts @@ -1,18 +1,30 @@ -import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; -import { useActiveConversation } from "./query/use-active-conversation"; import { useAgentState } from "#/hooks/use-agent-state"; +import { + RUNTIME_INACTIVE_STATES, + RUNTIME_STARTING_STATES, +} from "#/types/agent-state"; +import { useActiveConversation } from "./query/use-active-conversation"; + +interface UseRuntimeIsReadyOptions { + allowAgentError?: boolean; +} /** * Hook to determine if the runtime is ready for operations * * @returns boolean indicating if the runtime is ready */ -export const useRuntimeIsReady = (): boolean => { +export const useRuntimeIsReady = ({ + allowAgentError = false, +}: UseRuntimeIsReadyOptions = {}): boolean => { const { data: conversation } = useActiveConversation(); const { curAgentState } = useAgentState(); + const inactiveStates = allowAgentError + ? RUNTIME_STARTING_STATES + : RUNTIME_INACTIVE_STATES; return ( conversation?.status === "RUNNING" && - !RUNTIME_INACTIVE_STATES.includes(curAgentState) + !inactiveStates.includes(curAgentState) ); }; diff --git a/frontend/src/routes/vscode-tab.tsx b/frontend/src/routes/vscode-tab.tsx index e1bb2e8fe4..fe60a52dac 100644 --- a/frontend/src/routes/vscode-tab.tsx +++ b/frontend/src/routes/vscode-tab.tsx @@ -1,17 +1,17 @@ import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; -import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { RUNTIME_STARTING_STATES } from "#/types/agent-state"; import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags"; import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message"; -import { useAgentState } from "#/hooks/use-agent-state"; function VSCodeTab() { const { t } = useTranslation(); const { data, isLoading, error } = useUnifiedVSCodeUrl(); const { curAgentState } = useAgentState(); - const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState); + const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState); const iframeRef = React.useRef(null); const [isCrossProtocol, setIsCrossProtocol] = useState(false); const [iframeError, setIframeError] = useState(null); @@ -39,7 +39,7 @@ function VSCodeTab() { } }; - if (isRuntimeInactive) { + if (isRuntimeStarting) { return ; } diff --git a/frontend/src/types/agent-state.tsx b/frontend/src/types/agent-state.tsx index 9309ef5e41..ab05ea89df 100644 --- a/frontend/src/types/agent-state.tsx +++ b/frontend/src/types/agent-state.tsx @@ -14,9 +14,10 @@ export enum AgentState { USER_REJECTED = "user_rejected", } +export const RUNTIME_STARTING_STATES = [AgentState.INIT, AgentState.LOADING]; + export const RUNTIME_INACTIVE_STATES = [ - AgentState.INIT, - AgentState.LOADING, + ...RUNTIME_STARTING_STATES, // Removed AgentState.STOPPED to allow tabs to remain visible when agent is stopped AgentState.ERROR, ]; From 0ec962e96be6281a4603e851536bcd7953613f67 Mon Sep 17 00:00:00 2001 From: MkDev11 <94194147+MkDev11@users.noreply.github.com> Date: Thu, 19 Mar 2026 07:13:58 -0700 Subject: [PATCH 77/92] feat: add /clear endpoint for V1 conversations (#12786) Co-authored-by: mkdev11 Co-authored-by: openhands Co-authored-by: tofarr Co-authored-by: hieptl --- .../components/interactive-chat-box.test.tsx | 30 ++ .../use-new-conversation-command.test.tsx | 299 ++++++++++++++++++ .../__tests__/hooks/use-websocket.test.ts | 4 +- .../v1-conversation-service.api.ts | 4 + .../features/chat/chat-interface.tsx | 32 +- .../chat/components/chat-input-container.tsx | 3 + .../chat/components/chat-input-field.tsx | 10 +- .../chat/components/chat-input-row.tsx | 3 + .../features/chat/custom-chat-input.tsx | 3 + .../features/chat/interactive-chat-box.tsx | 8 +- .../mutation/use-new-conversation-command.ts | 115 +++++++ .../query/use-unified-get-git-changes.ts | 1 + frontend/src/i18n/declaration.ts | 8 + frontend/src/i18n/translation.json | 136 ++++++++ frontend/src/utils/websocket-url.ts | 13 + .../app_conversation_info_service.py | 8 + .../app_conversation_service.py | 26 +- .../live_status_app_conversation_service.py | 13 +- .../sql_app_conversation_info_service.py | 8 + openhands/app_server/config.py | 21 ++ .../sandbox/docker_sandbox_service.py | 17 +- openhands/server/middleware.py | 21 +- .../server/routes/manage_conversations.py | 18 +- .../test_sql_app_conversation_info_service.py | 48 +++ .../server/data_models/test_conversation.py | 12 +- tests/unit/server/test_middleware.py | 79 +++-- 26 files changed, 884 insertions(+), 56 deletions(-) create mode 100644 frontend/__tests__/hooks/mutation/use-new-conversation-command.test.tsx create mode 100644 frontend/src/hooks/mutation/use-new-conversation-command.ts diff --git a/frontend/__tests__/components/interactive-chat-box.test.tsx b/frontend/__tests__/components/interactive-chat-box.test.tsx index ecb6623806..884217facb 100644 --- a/frontend/__tests__/components/interactive-chat-box.test.tsx +++ b/frontend/__tests__/components/interactive-chat-box.test.tsx @@ -216,6 +216,36 @@ describe("InteractiveChatBox", () => { expect(onSubmitMock).not.toHaveBeenCalled(); }); + it("should lock the text input field when disabled prop is true (isNewConversationPending)", () => { + mockStores(AgentState.INIT); + + renderInteractiveChatBox({ + onSubmit: onSubmitMock, + disabled: true, + }); + + const chatInput = screen.getByTestId("chat-input"); + // When disabled=true, the text field should not be editable + expect(chatInput).toHaveAttribute("contenteditable", "false"); + // Should show visual disabled state + expect(chatInput.className).toContain("cursor-not-allowed"); + expect(chatInput.className).toContain("opacity-50"); + }); + + it("should keep the text input field editable when disabled prop is false", () => { + mockStores(AgentState.INIT); + + renderInteractiveChatBox({ + onSubmit: onSubmitMock, + disabled: false, + }); + + const chatInput = screen.getByTestId("chat-input"); + expect(chatInput).toHaveAttribute("contenteditable", "true"); + expect(chatInput.className).not.toContain("cursor-not-allowed"); + expect(chatInput.className).not.toContain("opacity-50"); + }); + it("should handle image upload and message submission correctly", async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); diff --git a/frontend/__tests__/hooks/mutation/use-new-conversation-command.test.tsx b/frontend/__tests__/hooks/mutation/use-new-conversation-command.test.tsx new file mode 100644 index 0000000000..07f110ff17 --- /dev/null +++ b/frontend/__tests__/hooks/mutation/use-new-conversation-command.test.tsx @@ -0,0 +1,299 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; +import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command"; + +const mockNavigate = vi.fn(); + +vi.mock("react-router", () => ({ + useNavigate: () => mockNavigate, + useParams: () => ({ conversationId: "conv-123" }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const { mockToast } = vi.hoisted(() => { + const mockToast = Object.assign(vi.fn(), { + loading: vi.fn(), + dismiss: vi.fn(), + }); + return { mockToast }; +}); + +vi.mock("react-hot-toast", () => ({ + default: mockToast, +})); + +vi.mock("#/utils/custom-toast-handlers", () => ({ + displaySuccessToast: vi.fn(), + displayErrorToast: vi.fn(), + TOAST_OPTIONS: { position: "top-right" }, +})); + +const mockConversation = { + conversation_id: "conv-123", + sandbox_id: "sandbox-456", + title: "Test Conversation", + selected_repository: null, + selected_branch: null, + git_provider: null, + last_updated_at: new Date().toISOString(), + created_at: new Date().toISOString(), + status: "RUNNING" as const, + runtime_status: null, + url: null, + session_api_key: null, + conversation_version: "V1" as const, +}; + +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: () => ({ + data: mockConversation, + }), +})); + +function makeStartTask(overrides: Record = {}) { + return { + id: "task-789", + created_by_user_id: null, + status: "READY", + detail: null, + app_conversation_id: "new-conv-999", + sandbox_id: "sandbox-456", + agent_server_url: "http://agent-server.local", + request: { + sandbox_id: null, + initial_message: null, + processors: [], + llm_model: null, + selected_repository: null, + selected_branch: null, + git_provider: null, + suggested_task: null, + title: null, + trigger: null, + pr_number: [], + parent_conversation_id: null, + agent_type: "default", + }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +describe("useNewConversationCommand", () => { + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + // Mock batchGetAppConversations to return V1 data with llm_model + vi.spyOn( + V1ConversationService, + "batchGetAppConversations", + ).mockResolvedValue([ + { + id: "conv-123", + title: "Test Conversation", + sandbox_id: "sandbox-456", + sandbox_status: "RUNNING", + execution_status: "IDLE", + conversation_url: null, + session_api_key: null, + selected_repository: null, + selected_branch: null, + git_provider: null, + trigger: null, + pr_number: [], + llm_model: "gpt-4o", + metrics: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + sub_conversation_ids: [], + public: false, + } as never, + ]); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it("calls createConversation with sandbox_id and navigates on success", async () => { + const readyTask = makeStartTask(); + const createSpy = vi + .spyOn(V1ConversationService, "createConversation") + .mockResolvedValue(readyTask as never); + const getStartTaskSpy = vi + .spyOn(V1ConversationService, "getStartTask") + .mockResolvedValue(readyTask as never); + + const { result } = renderHook(() => useNewConversationCommand(), { wrapper }); + + await result.current.mutateAsync(); + + await waitFor(() => { + expect(createSpy).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + "sandbox-456", + "gpt-4o", + ); + expect(getStartTaskSpy).toHaveBeenCalledWith("task-789"); + expect(mockNavigate).toHaveBeenCalledWith( + "/conversations/new-conv-999", + ); + }); + }); + + it("polls getStartTask until status is READY", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const workingTask = makeStartTask({ + status: "WORKING", + app_conversation_id: null, + }); + const readyTask = makeStartTask({ status: "READY" }); + + vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue( + workingTask as never, + ); + const getStartTaskSpy = vi + .spyOn(V1ConversationService, "getStartTask") + .mockResolvedValueOnce(workingTask as never) + .mockResolvedValueOnce(readyTask as never); + + const { result } = renderHook(() => useNewConversationCommand(), { wrapper }); + + const mutatePromise = result.current.mutateAsync(); + + await vi.advanceTimersByTimeAsync(2000); + await mutatePromise; + + await waitFor(() => { + expect(getStartTaskSpy).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenCalledWith( + "/conversations/new-conv-999", + ); + }); + + vi.useRealTimers(); + }); + + it("throws when task status is ERROR", async () => { + const errorTask = makeStartTask({ + status: "ERROR", + detail: "Sandbox crashed", + app_conversation_id: null, + }); + + vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue( + errorTask as never, + ); + vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue( + errorTask as never, + ); + + const { result } = renderHook(() => useNewConversationCommand(), { wrapper }); + + await expect(result.current.mutateAsync()).rejects.toThrow( + "Sandbox crashed", + ); + }); + + it("invalidates conversation list queries on success", async () => { + const readyTask = makeStartTask(); + + vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue( + readyTask as never, + ); + vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue( + readyTask as never, + ); + + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const { result } = renderHook(() => useNewConversationCommand(), { wrapper }); + + await result.current.mutateAsync(); + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ["user", "conversations"], + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ["v1-batch-get-app-conversations"], + }); + }); + }); + + it("creates a standalone conversation (not a sub-conversation) so it appears in the list", async () => { + const readyTask = makeStartTask(); + const createSpy = vi + .spyOn(V1ConversationService, "createConversation") + .mockResolvedValue(readyTask as never); + vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue( + readyTask as never, + ); + + const { result } = renderHook(() => useNewConversationCommand(), { wrapper }); + + await result.current.mutateAsync(); + + await waitFor(() => { + // parent_conversation_id should be undefined so the new conversation + // is NOT a sub-conversation and will appear in the conversation list. + expect(createSpy).toHaveBeenCalledWith( + undefined, // selectedRepository (null from mock) + undefined, // git_provider (null from mock) + undefined, // initialUserMsg + undefined, // selected_branch (null from mock) + undefined, // conversationInstructions + undefined, // suggestedTask + undefined, // trigger + undefined, // parent_conversation_id is NOT set + undefined, // agent_type + "sandbox-456", // sandbox_id IS set to reuse the sandbox + "gpt-4o", // llm_model IS inherited from the original conversation + ); + }); + }); + + it("shows a loading toast immediately and dismisses it on success", async () => { + const readyTask = makeStartTask(); + + vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue( + readyTask as never, + ); + vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue( + readyTask as never, + ); + + const { result } = renderHook(() => useNewConversationCommand(), { wrapper }); + + await result.current.mutateAsync(); + + await waitFor(() => { + expect(mockToast.loading).toHaveBeenCalledWith( + "CONVERSATION$CLEARING", + expect.objectContaining({ id: "clear-conversation" }), + ); + expect(mockToast.dismiss).toHaveBeenCalledWith("clear-conversation"); + }); + }); +}); diff --git a/frontend/__tests__/hooks/use-websocket.test.ts b/frontend/__tests__/hooks/use-websocket.test.ts index 7d42507a87..d00db6f856 100644 --- a/frontend/__tests__/hooks/use-websocket.test.ts +++ b/frontend/__tests__/hooks/use-websocket.test.ts @@ -205,7 +205,9 @@ describe("useWebSocket", () => { expect(result.current.isConnected).toBe(true); }); - expect(onCloseSpy).not.toHaveBeenCalled(); + // Reset spy after connection is established to ignore any spurious + // close events fired by the MSW mock during the handshake. + onCloseSpy.mockClear(); // Unmount to trigger close unmount(); diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index a0e99abe0f..bcdad50077 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -68,6 +68,8 @@ class V1ConversationService { trigger?: ConversationTrigger, parent_conversation_id?: string, agent_type?: "default" | "plan", + sandbox_id?: string, + llm_model?: string, ): Promise { const body: V1AppConversationStartRequest = { selected_repository: selectedRepository, @@ -78,6 +80,8 @@ class V1ConversationService { trigger, parent_conversation_id: parent_conversation_id || null, agent_type, + sandbox_id: sandbox_id || null, + llm_model: llm_model || null, }; // suggested_task implies the backend will construct the initial_message diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index 43218149ae..3b11a2fbc4 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -38,6 +38,8 @@ import { useTaskPolling } from "#/hooks/query/use-task-polling"; import { useConversationWebSocket } from "#/contexts/conversation-websocket-context"; import ChatStatusIndicator from "./chat-status-indicator"; import { getStatusColor, getStatusText } from "#/utils/utils"; +import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command"; +import { I18nKey } from "#/i18n/declaration"; function getEntryPoint( hasRepository: boolean | null, @@ -80,6 +82,10 @@ export function ChatInterface() { setHitBottom, } = useScrollToBottom(scrollRef); const { data: config } = useConfig(); + const { + mutate: newConversationCommand, + isPending: isNewConversationPending, + } = useNewConversationCommand(); const { curAgentState } = useAgentState(); const { handleBuildPlanClick } = useHandleBuildPlanClick(); @@ -146,6 +152,27 @@ export function ChatInterface() { originalImages: File[], originalFiles: File[], ) => { + // Handle /new command for V1 conversations + if (content.trim() === "/new") { + if (!isV1Conversation) { + displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_V1_ONLY)); + return; + } + if (!params.conversationId) { + displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_NO_ID)); + return; + } + if (totalEvents === 0) { + displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_EMPTY)); + return; + } + if (isNewConversationPending) { + return; + } + newConversationCommand(); + return; + } + // Create mutable copies of the arrays const images = [...originalImages]; const files = [...originalFiles]; @@ -338,7 +365,10 @@ export function ChatInterface() { /> )} - +
{config?.app_mode !== "saas" && !isV1Conversation && ( diff --git a/frontend/src/components/features/chat/components/chat-input-container.tsx b/frontend/src/components/features/chat/components/chat-input-container.tsx index ef67069de5..ebb3924458 100644 --- a/frontend/src/components/features/chat/components/chat-input-container.tsx +++ b/frontend/src/components/features/chat/components/chat-input-container.tsx @@ -12,6 +12,7 @@ interface ChatInputContainerProps { chatContainerRef: React.RefObject; isDragOver: boolean; disabled: boolean; + isNewConversationPending?: boolean; showButton: boolean; buttonClassName: string; chatInputRef: React.RefObject; @@ -36,6 +37,7 @@ export function ChatInputContainer({ chatContainerRef, isDragOver, disabled, + isNewConversationPending = false, showButton, buttonClassName, chatInputRef, @@ -89,6 +91,7 @@ export function ChatInputContainer({ ; + disabled?: boolean; onInput: () => void; onPaste: (e: React.ClipboardEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; @@ -14,6 +16,7 @@ interface ChatInputFieldProps { export function ChatInputField({ chatInputRef, + disabled = false, onInput, onPaste, onKeyDown, @@ -36,8 +39,11 @@ export function ChatInputField({
; disabled: boolean; + isNewConversationPending?: boolean; showButton: boolean; buttonClassName: string; handleFileIconClick: (isDisabled: boolean) => void; @@ -21,6 +22,7 @@ interface ChatInputRowProps { export function ChatInputRow({ chatInputRef, disabled, + isNewConversationPending = false, showButton, buttonClassName, handleFileIconClick, @@ -41,6 +43,7 @@ export function ChatInputRow({ void; @@ -25,6 +26,7 @@ export interface CustomChatInputProps { export function CustomChatInput({ disabled = false, + isNewConversationPending = false, showButton = true, conversationStatus = null, onSubmit, @@ -147,6 +149,7 @@ export function CustomChatInput({ chatContainerRef={chatContainerRef} isDragOver={isDragOver} disabled={isDisabled} + isNewConversationPending={isNewConversationPending} showButton={showButton} buttonClassName={buttonClassName} chatInputRef={chatInputRef} diff --git a/frontend/src/components/features/chat/interactive-chat-box.tsx b/frontend/src/components/features/chat/interactive-chat-box.tsx index 74818d1d6c..cf46336887 100644 --- a/frontend/src/components/features/chat/interactive-chat-box.tsx +++ b/frontend/src/components/features/chat/interactive-chat-box.tsx @@ -13,9 +13,13 @@ import { isTaskPolling } from "#/utils/utils"; interface InteractiveChatBoxProps { onSubmit: (message: string, images: File[], files: File[]) => void; + disabled?: boolean; } -export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) { +export function InteractiveChatBox({ + onSubmit, + disabled = false, +}: InteractiveChatBoxProps) { const { images, files, @@ -145,6 +149,7 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) { // Allow users to submit messages during LOADING state - they will be // queued server-side and delivered when the conversation becomes ready const isDisabled = + disabled || curAgentState === AgentState.AWAITING_USER_CONFIRMATION || isTaskPolling(subConversationTaskStatus); @@ -152,6 +157,7 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
{ + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const { data: conversation } = useActiveConversation(); + + const mutation = useMutation({ + mutationFn: async () => { + if (!conversation?.conversation_id || !conversation.sandbox_id) { + throw new Error("No active conversation or sandbox"); + } + + // Fetch V1 conversation data to get llm_model (not available in legacy type) + const v1Conversations = + await V1ConversationService.batchGetAppConversations([ + conversation.conversation_id, + ]); + const llmModel = v1Conversations?.[0]?.llm_model; + + // Start a new conversation reusing the existing sandbox directly. + // We pass sandbox_id instead of parent_conversation_id so that the + // new conversation is NOT marked as a sub-conversation and will + // appear in the conversation list. + const startTask = await V1ConversationService.createConversation( + conversation.selected_repository ?? undefined, // selectedRepository + conversation.git_provider ?? undefined, // git_provider + undefined, // initialUserMsg + conversation.selected_branch ?? undefined, // selected_branch + undefined, // conversationInstructions + undefined, // suggestedTask + undefined, // trigger + undefined, // parent_conversation_id + undefined, // agent_type + conversation.sandbox_id ?? undefined, // sandbox_id - reuse the same sandbox + llmModel ?? undefined, // llm_model - preserve the LLM model + ); + + // Poll for the task to complete and get the new conversation ID + let task = await V1ConversationService.getStartTask(startTask.id); + const maxAttempts = 60; // 60 seconds timeout + let attempts = 0; + + /* eslint-disable no-await-in-loop */ + while ( + task && + !["READY", "ERROR"].includes(task.status) && + attempts < maxAttempts + ) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + task = await V1ConversationService.getStartTask(startTask.id); + attempts += 1; + } + + if (!task || task.status !== "READY" || !task.app_conversation_id) { + throw new Error( + task?.detail || "Failed to create new conversation in sandbox", + ); + } + + return { + newConversationId: task.app_conversation_id, + oldConversationId: conversation.conversation_id, + }; + }, + onMutate: () => { + toast.loading(t(I18nKey.CONVERSATION$CLEARING), { + ...TOAST_OPTIONS, + id: "clear-conversation", + }); + }, + onSuccess: (data) => { + toast.dismiss("clear-conversation"); + displaySuccessToast(t(I18nKey.CONVERSATION$CLEAR_SUCCESS)); + navigate(`/conversations/${data.newConversationId}`); + + // Refresh the sidebar to show the new conversation. + queryClient.invalidateQueries({ + queryKey: ["user", "conversations"], + }); + queryClient.invalidateQueries({ + queryKey: ["v1-batch-get-app-conversations"], + }); + }, + onError: (error) => { + toast.dismiss("clear-conversation"); + let clearError = t(I18nKey.CONVERSATION$CLEAR_UNKNOWN_ERROR); + if (error instanceof Error) { + clearError = error.message; + } else if (typeof error === "string") { + clearError = error; + } + displayErrorToast( + t(I18nKey.CONVERSATION$CLEAR_FAILED, { error: clearError }), + ); + }, + }); + + return mutation; +}; diff --git a/frontend/src/hooks/query/use-unified-get-git-changes.ts b/frontend/src/hooks/query/use-unified-get-git-changes.ts index 801b1a067a..a1de3852f9 100644 --- a/frontend/src/hooks/query/use-unified-get-git-changes.ts +++ b/frontend/src/hooks/query/use-unified-get-git-changes.ts @@ -57,6 +57,7 @@ export const useUnifiedGetGitChanges = () => { retry: false, staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes + refetchOnMount: "always", // Always refetch when mounting (e.g. navigating between conversations that share a sandbox) enabled: runtimeIsReady && !!conversationId, meta: { disableToast: true, diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index fe6f248cfa..9b355ae432 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1151,6 +1151,14 @@ export enum I18nKey { ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON", ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON", ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON", + CONVERSATION$CLEAR_V1_ONLY = "CONVERSATION$CLEAR_V1_ONLY", + CONVERSATION$CLEAR_EMPTY = "CONVERSATION$CLEAR_EMPTY", + CONVERSATION$CLEAR_NO_ID = "CONVERSATION$CLEAR_NO_ID", + CONVERSATION$CLEAR_NO_NEW_ID = "CONVERSATION$CLEAR_NO_NEW_ID", + CONVERSATION$CLEAR_UNKNOWN_ERROR = "CONVERSATION$CLEAR_UNKNOWN_ERROR", + CONVERSATION$CLEAR_FAILED = "CONVERSATION$CLEAR_FAILED", + CONVERSATION$CLEAR_SUCCESS = "CONVERSATION$CLEAR_SUCCESS", + CONVERSATION$CLEARING = "CONVERSATION$CLEARING", CTA$ENTERPRISE = "CTA$ENTERPRISE", CTA$ENTERPRISE_DEPLOY = "CTA$ENTERPRISE_DEPLOY", CTA$FEATURE_ON_PREMISES = "CTA$FEATURE_ON_PREMISES", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index f43c33b0d2..57b89cd193 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -19569,6 +19569,142 @@ "uk": "Завершити", "ca": "Finalitza" }, + "CONVERSATION$CLEAR_V1_ONLY": { + "en": "The /new command is only available for V1 conversations", + "ja": "/newコマンドはV1会話でのみ使用できます", + "zh-CN": "/new 命令仅适用于 V1 对话", + "zh-TW": "/new 指令僅適用於 V1 對話", + "ko-KR": "/new 명령은 V1 대화에서만 사용할 수 있습니다", + "no": "/new-kommandoen er kun tilgjengelig for V1-samtaler", + "it": "Il comando /new è disponibile solo per le conversazioni V1", + "pt": "O comando /new está disponível apenas para conversas V1", + "es": "El comando /new solo está disponible para conversaciones V1", + "ar": "أمر /new متاح فقط لمحادثات V1", + "fr": "La commande /new n'est disponible que pour les conversations V1", + "tr": "/new komutu yalnızca V1 konuşmalarında kullanılabilir", + "de": "Der /new-Befehl ist nur für V1-Konversationen verfügbar", + "uk": "Команда /new доступна лише для розмов V1", + "ca": "L'ordre /new només està disponible per a converses V1" + }, + "CONVERSATION$CLEAR_EMPTY": { + "en": "Nothing to clear. This conversation has no messages yet.", + "ja": "クリアするものがありません。この会話にはまだメッセージがありません。", + "zh-CN": "没有可清除的内容。此对话尚无消息。", + "zh-TW": "沒有可清除的內容。此對話尚無訊息。", + "ko-KR": "지울 내용이 없습니다. 이 대화에는 아직 메시지가 없습니다.", + "no": "Ingenting å tømme. Denne samtalen har ingen meldinger ennå.", + "it": "Niente da cancellare. Questa conversazione non ha ancora messaggi.", + "pt": "Nada para limpar. Esta conversa ainda não tem mensagens.", + "es": "Nada que borrar. Esta conversación aún no tiene mensajes.", + "ar": "لا يوجد شيء لمسحه. لا تحتوي هذه المحادثة على رسائل بعد.", + "fr": "Rien à effacer. Cette conversation n'a pas encore de messages.", + "tr": "Temizlenecek bir şey yok. Bu konuşmada henüz mesaj yok.", + "de": "Nichts zu löschen. Diese Konversation hat noch keine Nachrichten.", + "uk": "Нічого очищувати. Ця розмова ще не має повідомлень.", + "ca": "No hi ha res a esborrar. Aquesta conversa encara no té missatges." + }, + "CONVERSATION$CLEAR_NO_ID": { + "en": "No conversation ID found", + "ja": "会話IDが見つかりません", + "zh-CN": "未找到对话 ID", + "zh-TW": "找不到對話 ID", + "ko-KR": "대화 ID를 찾을 수 없습니다", + "no": "Ingen samtale-ID funnet", + "it": "Nessun ID conversazione trovato", + "pt": "Nenhum ID de conversa encontrado", + "es": "No se encontró el ID de conversación", + "ar": "لم يتم العثور على معرف المحادثة", + "fr": "Aucun identifiant de conversation trouvé", + "tr": "Konuşma kimliği bulunamadı", + "de": "Keine Konversations-ID gefunden", + "uk": "Ідентифікатор розмови не знайдено", + "ca": "No s'ha trobat l'identificador de la conversa" + }, + "CONVERSATION$CLEAR_NO_NEW_ID": { + "en": "Server did not return a new conversation ID", + "ja": "サーバーが新しい会話IDを返しませんでした", + "zh-CN": "服务器未返回新的对话 ID", + "zh-TW": "伺服器未返回新的對話 ID", + "ko-KR": "서버가 새 대화 ID를 반환하지 않았습니다", + "no": "Serveren returnerte ikke en ny samtale-ID", + "it": "Il server non ha restituito un nuovo ID conversazione", + "pt": "O servidor não retornou um novo ID de conversa", + "es": "El servidor no devolvió un nuevo ID de conversación", + "ar": "لم يقم الخادم بإرجاع معرف محادثة جديد", + "fr": "Le serveur n'a pas renvoyé un nouvel identifiant de conversation", + "tr": "Sunucu yeni bir konuşma kimliği döndürmedi", + "de": "Der Server hat keine neue Konversations-ID zurückgegeben", + "uk": "Сервер не повернув новий ідентифікатор розмови", + "ca": "El servidor no ha retornat un nou identificador de conversa" + }, + "CONVERSATION$CLEAR_UNKNOWN_ERROR": { + "en": "Unknown error", + "ja": "不明なエラー", + "zh-CN": "未知错误", + "zh-TW": "未知錯誤", + "ko-KR": "알 수 없는 오류", + "no": "Ukjent feil", + "it": "Errore sconosciuto", + "pt": "Erro desconhecido", + "es": "Error desconocido", + "ar": "خطأ غير معروف", + "fr": "Erreur inconnue", + "tr": "Bilinmeyen hata", + "de": "Unbekannter Fehler", + "uk": "Невідома помилка", + "ca": "Error desconegut" + }, + "CONVERSATION$CLEAR_FAILED": { + "en": "Failed to start new conversation: {{error}}", + "ja": "新しい会話の開始に失敗しました: {{error}}", + "zh-CN": "启动新对话失败: {{error}}", + "zh-TW": "啟動新對話失敗: {{error}}", + "ko-KR": "새 대화 시작 실패: {{error}}", + "no": "Kunne ikke starte ny samtale: {{error}}", + "it": "Impossibile avviare una nuova conversazione: {{error}}", + "pt": "Falha ao iniciar nova conversa: {{error}}", + "es": "Error al iniciar nueva conversación: {{error}}", + "ar": "فشل في بدء محادثة جديدة: {{error}}", + "fr": "Échec du démarrage d'une nouvelle conversation : {{error}}", + "tr": "Yeni konuşma başlatılamadı: {{error}}", + "de": "Neue Konversation konnte nicht gestartet werden: {{error}}", + "uk": "Не вдалося розпочати нову розмову: {{error}}", + "ca": "No s'ha pogut iniciar una nova conversa: {{error}}" + }, + "CONVERSATION$CLEAR_SUCCESS": { + "en": "Starting a new conversation in the same sandbox. These conversations share the same runtime.", + "ja": "同じサンドボックスで新しい会話を開始します。これらの会話は同じランタイムを共有します。", + "zh-CN": "正在同一沙箱中开始新对话。这些对话共享同一运行时。", + "zh-TW": "正在同一沙盒中開始新對話。這些對話共享同一執行環境。", + "ko-KR": "같은 샌드박스에서 새 대화를 시작합니다. 이 대화들은 같은 런타임을 공유합니다.", + "no": "Starter ny samtale i samme sandbox. Disse samtalene deler samme kjøretid.", + "it": "Avvio nuova conversazione nello stesso sandbox. Queste conversazioni condividono lo stesso runtime.", + "pt": "Iniciando nova conversa no mesmo sandbox. Essas conversas compartilham o mesmo runtime.", + "es": "Iniciando nueva conversación en el mismo sandbox. Estas conversaciones comparten el mismo runtime.", + "ar": "بدء محادثة جديدة في نفس صندوق الحماية. هذه المحادثات تشارك نفس وقت التشغيل.", + "fr": "Démarrage d'une nouvelle conversation dans le même bac à sable. Ces conversations partagent le même environnement d'exécution.", + "tr": "Aynı korumalı alanda yeni konuşma başlatılıyor. Bu konuşmalar aynı çalışma ortamını paylaşır.", + "de": "Starte neue Konversation in derselben Sandbox. Diese Konversationen teilen dieselbe Laufzeitumgebung.", + "uk": "Починаю нову розмову в тому самому захищеному середовищі. Ці розмови використовують одне середовище виконання.", + "ca": "S'està iniciant una nova conversa al mateix entorn aïllat. Aquestes converses comparteixen el mateix entorn d'execució." + }, + "CONVERSATION$CLEARING": { + "en": "Creating new conversation...", + "ja": "新しい会話を作成中...", + "zh-CN": "正在创建新对话...", + "zh-TW": "正在建立新對話...", + "ko-KR": "새 대화를 만드는 중...", + "no": "Oppretter ny samtale...", + "it": "Creazione nuova conversazione...", + "pt": "Criando nova conversa...", + "es": "Creando nueva conversación...", + "ar": "جارٍ إنشاء محادثة جديدة...", + "fr": "Création d'une nouvelle conversation...", + "tr": "Yeni konuşma oluşturuluyor...", + "de": "Neue Konversation wird erstellt...", + "uk": "Створення нової розмови...", + "ca": "S'està creant una nova conversa..." + }, "CTA$ENTERPRISE": { "en": "Enterprise", "ja": "エンタープライズ", diff --git a/frontend/src/utils/websocket-url.ts b/frontend/src/utils/websocket-url.ts index 0e72c24dc8..787032b2c9 100644 --- a/frontend/src/utils/websocket-url.ts +++ b/frontend/src/utils/websocket-url.ts @@ -9,6 +9,19 @@ export function extractBaseHost( if (conversationUrl && !conversationUrl.startsWith("/")) { try { const url = new URL(conversationUrl); + // If the conversation URL points to localhost but we're accessing from external, + // use the browser's hostname with the conversation URL's port + const urlHostname = url.hostname; + const browserHostname = + window.location.hostname ?? window.location.host?.split(":")[0]; + if ( + browserHostname && + (urlHostname === "localhost" || urlHostname === "127.0.0.1") && + browserHostname !== "localhost" && + browserHostname !== "127.0.0.1" + ) { + return `${browserHostname}:${url.port}`; + } return url.host; // e.g., "localhost:3000" } catch { return window.location.host; diff --git a/openhands/app_server/app_conversation/app_conversation_info_service.py b/openhands/app_server/app_conversation/app_conversation_info_service.py index bb83ab5801..e14f1dbf6e 100644 --- a/openhands/app_server/app_conversation/app_conversation_info_service.py +++ b/openhands/app_server/app_conversation/app_conversation_info_service.py @@ -84,6 +84,14 @@ class AppConversationInfoService(ABC): List of sub-conversation IDs """ + @abstractmethod + async def count_conversations_by_sandbox_id(self, sandbox_id: str) -> int: + """Count V1 conversations that reference the given sandbox. + + Used to decide whether a sandbox can be safely deleted when a + conversation is removed (only delete if count is 0). + """ + # Mutators @abstractmethod diff --git a/openhands/app_server/app_conversation/app_conversation_service.py b/openhands/app_server/app_conversation/app_conversation_service.py index 1f955cac9c..6be1d32ddf 100644 --- a/openhands/app_server/app_conversation/app_conversation_service.py +++ b/openhands/app_server/app_conversation/app_conversation_service.py @@ -77,8 +77,20 @@ class AppConversationService(ABC): id, starting a conversation, attaching a callback, and then running the conversation. - Yields an instance of AppConversationStartTask as updates occur, which can be used to determine - the progress of the task. + This method returns an async iterator that yields the same + AppConversationStartTask repeatedly as status updates occur. Callers + should iterate until the task reaches a terminal status:: + + async for task in service.start_app_conversation(request): + if task.status in ( + AppConversationStartTaskStatus.READY, + AppConversationStartTaskStatus.ERROR, + ): + break + + Status progression: WORKING → WAITING_FOR_SANDBOX → PREPARING_REPOSITORY + → RUNNING_SETUP_SCRIPT → SETTING_UP_GIT_HOOKS → SETTING_UP_SKILLS + → STARTING_CONVERSATION → READY (or ERROR at any point). """ # This is an abstract method - concrete implementations should provide real values from openhands.app_server.app_conversation.app_conversation_models import ( @@ -111,15 +123,21 @@ class AppConversationService(ABC): """ @abstractmethod - async def delete_app_conversation(self, conversation_id: UUID) -> bool: + async def delete_app_conversation( + self, conversation_id: UUID, skip_agent_server_delete: bool = False + ) -> bool: """Delete a V1 conversation and all its associated data. Args: conversation_id: The UUID of the conversation to delete. + skip_agent_server_delete: If True, skip the agent server DELETE call. + This should be set when the sandbox is shared with other + conversations (e.g. created via /new) to avoid destabilizing + the shared runtime. This method should: 1. Delete the conversation from the database - 2. Call the agent server to delete the conversation + 2. Call the agent server to delete the conversation (unless skipped) 3. Clean up any related data Returns True if the conversation was deleted successfully, False otherwise. diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 703899ec83..b85e1de48f 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -1740,13 +1740,19 @@ class LiveStatusAppConversationService(AppConversationServiceBase): conversations = await self._build_app_conversations([info]) return conversations[0] - async def delete_app_conversation(self, conversation_id: UUID) -> bool: + async def delete_app_conversation( + self, conversation_id: UUID, skip_agent_server_delete: bool = False + ) -> bool: """Delete a V1 conversation and all its associated data. This method will also cascade delete all sub-conversations of the parent. Args: conversation_id: The UUID of the conversation to delete. + skip_agent_server_delete: If True, skip the agent server DELETE call. + This should be set when the sandbox is shared with other + conversations (e.g. created via /new) to avoid destabilizing + the shared runtime. """ # Check if we have the required SQL implementation for transactional deletion if not isinstance( @@ -1772,8 +1778,9 @@ class LiveStatusAppConversationService(AppConversationServiceBase): await self._delete_sub_conversations(conversation_id) # Now delete the parent conversation - # Delete from agent server if sandbox is running - await self._delete_from_agent_server(app_conversation) + # Delete from agent server if sandbox is running (skip if sandbox is shared) + if not skip_agent_server_delete: + await self._delete_from_agent_server(app_conversation) # Delete from database using the conversation info from app_conversation # AppConversation extends AppConversationInfo, so we can use it directly diff --git a/openhands/app_server/app_conversation/sql_app_conversation_info_service.py b/openhands/app_server/app_conversation/sql_app_conversation_info_service.py index c7c9e1935e..80b77957ba 100644 --- a/openhands/app_server/app_conversation/sql_app_conversation_info_service.py +++ b/openhands/app_server/app_conversation/sql_app_conversation_info_service.py @@ -278,6 +278,14 @@ class SQLAppConversationInfoService(AppConversationInfoService): rows = result_set.scalars().all() return [UUID(row.conversation_id) for row in rows] + async def count_conversations_by_sandbox_id(self, sandbox_id: str) -> int: + query = await self._secure_select() + query = query.where(StoredConversationMetadata.sandbox_id == sandbox_id) + count_query = select(func.count()).select_from(query.subquery()) + result = await self.db_session.execute(count_query) + count = result.scalar() + return count or 0 + async def get_app_conversation_info( self, conversation_id: UUID ) -> AppConversationInfo | None: diff --git a/openhands/app_server/config.py b/openhands/app_server/config.py index 4b7f78e389..96168143ed 100644 --- a/openhands/app_server/config.py +++ b/openhands/app_server/config.py @@ -87,6 +87,19 @@ def get_default_web_url() -> str | None: return f'https://{web_host}' +def get_default_permitted_cors_origins() -> list[str]: + """Get permitted CORS origins, falling back to legacy PERMITTED_CORS_ORIGINS env var. + + The preferred configuration is via OH_PERMITTED_CORS_ORIGINS_0, _1, etc. + (handled by the pydantic from_env parser). This fallback supports the legacy + comma-separated PERMITTED_CORS_ORIGINS environment variable. + """ + legacy = os.getenv('PERMITTED_CORS_ORIGINS', '') + if legacy: + return [o.strip() for o in legacy.split(',') if o.strip()] + return [] + + def get_openhands_provider_base_url() -> str | None: """Return the base URL for the OpenHands provider, if configured.""" return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or None @@ -106,6 +119,14 @@ class AppServerConfig(OpenHandsModel): default_factory=get_default_web_url, description='The URL where OpenHands is running (e.g., http://localhost:3000)', ) + permitted_cors_origins: list[str] = Field( + default_factory=get_default_permitted_cors_origins, + description=( + 'Additional permitted CORS origins for both the app server and agent ' + 'server containers. Configure via OH_PERMITTED_CORS_ORIGINS_0, _1, etc. ' + 'Falls back to legacy PERMITTED_CORS_ORIGINS env var.' + ), + ) openhands_provider_base_url: str | None = Field( default_factory=get_openhands_provider_base_url, description='Base URL for the OpenHands provider', diff --git a/openhands/app_server/sandbox/docker_sandbox_service.py b/openhands/app_server/sandbox/docker_sandbox_service.py index f5a302fa73..cccd873cb6 100644 --- a/openhands/app_server/sandbox/docker_sandbox_service.py +++ b/openhands/app_server/sandbox/docker_sandbox_service.py @@ -27,7 +27,6 @@ from openhands.app_server.sandbox.sandbox_models import ( SandboxStatus, ) from openhands.app_server.sandbox.sandbox_service import ( - ALLOW_CORS_ORIGINS_VARIABLE, SESSION_API_KEY_VARIABLE, WEBHOOK_CALLBACK_VARIABLE, SandboxService, @@ -91,6 +90,7 @@ class DockerSandboxService(SandboxService): httpx_client: httpx.AsyncClient max_num_sandboxes: int web_url: str | None = None + permitted_cors_origins: list[str] = field(default_factory=list) extra_hosts: dict[str, str] = field(default_factory=dict) docker_client: docker.DockerClient = field(default_factory=get_docker_client) startup_grace_seconds: int = STARTUP_GRACE_SECONDS @@ -386,8 +386,18 @@ class DockerSandboxService(SandboxService): # Set CORS origins for remote browser access when web_url is configured. # This allows the agent-server container to accept requests from the # frontend when running OpenHands on a remote machine. + # Each origin gets its own indexed env var (OH_ALLOW_CORS_ORIGINS_0, _1, etc.) + cors_origins: list[str] = [] if self.web_url: - env_vars[ALLOW_CORS_ORIGINS_VARIABLE] = self.web_url + cors_origins.append(self.web_url) + cors_origins.extend(self.permitted_cors_origins) + # Deduplicate while preserving order + seen: set[str] = set() + for origin in cors_origins: + if origin not in seen: + seen.add(origin) + idx = len(seen) - 1 + env_vars[f'OH_ALLOW_CORS_ORIGINS_{idx}'] = origin # Prepare port mappings and add port environment variables # When using host network, container ports are directly accessible on the host @@ -621,7 +631,7 @@ class DockerSandboxServiceInjector(SandboxServiceInjector): get_sandbox_spec_service, ) - # Get web_url from global config for CORS support + # Get web_url and permitted_cors_origins from global config config = get_global_config() web_url = config.web_url @@ -640,6 +650,7 @@ class DockerSandboxServiceInjector(SandboxServiceInjector): httpx_client=httpx_client, max_num_sandboxes=self.max_num_sandboxes, web_url=web_url, + permitted_cors_origins=config.permitted_cors_origins, extra_hosts=self.extra_hosts, startup_grace_seconds=self.startup_grace_seconds, use_host_network=self.use_host_network, diff --git a/openhands/server/middleware.py b/openhands/server/middleware.py index 902a881df8..b1e9c5649e 100644 --- a/openhands/server/middleware.py +++ b/openhands/server/middleware.py @@ -7,7 +7,7 @@ # Tag: Legacy-V0 # This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/. import asyncio -import os +import logging from collections import defaultdict from datetime import datetime, timedelta from urllib.parse import urlparse @@ -20,6 +20,8 @@ from starlette.requests import Request as StarletteRequest from starlette.responses import Response from starlette.types import ASGIApp +from openhands.app_server.config import get_global_config + class LocalhostCORSMiddleware(CORSMiddleware): """Custom CORS middleware that allows any request from localhost/127.0.0.1 domains, @@ -27,13 +29,8 @@ class LocalhostCORSMiddleware(CORSMiddleware): """ def __init__(self, app: ASGIApp) -> None: - allow_origins_str = os.getenv('PERMITTED_CORS_ORIGINS') - if allow_origins_str: - allow_origins = tuple( - origin.strip() for origin in allow_origins_str.split(',') - ) - else: - allow_origins = () + config = get_global_config() + allow_origins = tuple(config.permitted_cors_origins) super().__init__( app, allow_origins=allow_origins, @@ -51,6 +48,14 @@ class LocalhostCORSMiddleware(CORSMiddleware): if hostname in ['localhost', '127.0.0.1']: return True + # Allow any origin when no specific origins are configured (development mode) + # WARNING: This disables CORS protection. Use explicit CORS origins in production. + logging.getLogger(__name__).warning( + f'No CORS origins configured, allowing origin: {origin}. ' + 'Set OH_PERMITTED_CORS_ORIGINS for production environments.' + ) + return True + # For missing origin or other origins, use the parent class's logic result: bool = super().is_allowed_origin(origin) return result diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index fa73aa4d52..5789e9784c 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -603,16 +603,28 @@ async def _try_delete_v1_conversation( ) ) if app_conversation_info: + # Check if the sandbox is shared with other conversations + # (e.g. multiple conversations can share a sandbox via /new). + # If shared, skip the agent server DELETE call to avoid + # destabilizing the runtime for the remaining conversations. + sandbox_id = app_conversation_info.sandbox_id + sandbox_is_shared = False + if sandbox_id: + conversation_count = await app_conversation_info_service.count_conversations_by_sandbox_id( + sandbox_id + ) + sandbox_is_shared = conversation_count > 1 + # This is a V1 conversation, delete it using the app conversation service - # Pass the conversation ID for secure deletion result = await app_conversation_service.delete_app_conversation( - app_conversation_info.id + app_conversation_info.id, + skip_agent_server_delete=sandbox_is_shared, ) # Manually commit so that the conversation will vanish from the list await db_session.commit() - # Delete the sandbox in the background + # Delete the sandbox in the background (checks remaining conversations first) asyncio.create_task( _finalize_delete_and_close_connections( sandbox_service, diff --git a/tests/unit/app_server/test_sql_app_conversation_info_service.py b/tests/unit/app_server/test_sql_app_conversation_info_service.py index a491fa93af..48e9693641 100644 --- a/tests/unit/app_server/test_sql_app_conversation_info_service.py +++ b/tests/unit/app_server/test_sql_app_conversation_info_service.py @@ -286,6 +286,54 @@ class TestSQLAppConversationInfoService: results = await service.batch_get_app_conversation_info([]) assert results == [] + @pytest.mark.asyncio + async def test_count_conversations_by_sandbox_id( + self, + service: SQLAppConversationInfoService, + ): + """Test count by sandbox_id: only delete sandbox when no conversation uses it.""" + base_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + shared_sandbox = 'shared_sandbox_1' + other_sandbox = 'other_sandbox' + for i in range(3): + info = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id=shared_sandbox, + selected_repository='https://github.com/test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + title=f'Conversation {i}', + trigger=ConversationTrigger.GUI, + pr_number=[], + llm_model='gpt-4', + metrics=None, + created_at=base_time, + updated_at=base_time, + ) + await service.save_app_conversation_info(info) + for i in range(2): + info = AppConversationInfo( + id=uuid4(), + created_by_user_id=None, + sandbox_id=other_sandbox, + selected_repository='https://github.com/test/repo', + selected_branch='main', + git_provider=ProviderType.GITHUB, + title=f'Other {i}', + trigger=ConversationTrigger.GUI, + pr_number=[], + llm_model='gpt-4', + metrics=None, + created_at=base_time, + updated_at=base_time, + ) + await service.save_app_conversation_info(info) + + assert await service.count_conversations_by_sandbox_id(shared_sandbox) == 3 + assert await service.count_conversations_by_sandbox_id(other_sandbox) == 2 + assert await service.count_conversations_by_sandbox_id('no_such_sandbox') == 0 + @pytest.mark.asyncio async def test_search_conversation_info_no_filters( self, diff --git a/tests/unit/server/data_models/test_conversation.py b/tests/unit/server/data_models/test_conversation.py index 99dbdfaacc..3c84afd0c6 100644 --- a/tests/unit/server/data_models/test_conversation.py +++ b/tests/unit/server/data_models/test_conversation.py @@ -1038,6 +1038,9 @@ async def test_delete_v1_conversation_success(): return_value=mock_app_conversation_info ) mock_service.delete_app_conversation = AsyncMock(return_value=True) + mock_info_service.count_conversations_by_sandbox_id = AsyncMock( + return_value=1 + ) # Call delete_conversation with V1 conversation ID result = await delete_conversation( @@ -1059,7 +1062,8 @@ async def test_delete_v1_conversation_success(): # Verify that delete_app_conversation was called with the conversation ID mock_service.delete_app_conversation.assert_called_once_with( - conversation_uuid + conversation_uuid, + skip_agent_server_delete=False, ) @@ -1357,6 +1361,9 @@ async def test_delete_v1_conversation_with_agent_server(): return_value=mock_app_conversation_info ) mock_service.delete_app_conversation = AsyncMock(return_value=True) + mock_info_service.count_conversations_by_sandbox_id = AsyncMock( + return_value=1 + ) # Call delete_conversation with V1 conversation ID result = await delete_conversation( @@ -1378,7 +1385,8 @@ async def test_delete_v1_conversation_with_agent_server(): # Verify that delete_app_conversation was called with the conversation ID mock_service.delete_app_conversation.assert_called_once_with( - conversation_uuid + conversation_uuid, + skip_agent_server_delete=False, ) diff --git a/tests/unit/server/test_middleware.py b/tests/unit/server/test_middleware.py index 2bdf2275fc..cdc922b5e9 100644 --- a/tests/unit/server/test_middleware.py +++ b/tests/unit/server/test_middleware.py @@ -1,5 +1,4 @@ -import os -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from fastapi import FastAPI @@ -21,34 +20,46 @@ def app(): return app -def test_localhost_cors_middleware_init_with_env_var(): - """Test that the middleware correctly parses PERMITTED_CORS_ORIGINS environment variable.""" - with patch.dict( - os.environ, {'PERMITTED_CORS_ORIGINS': 'https://example.com,https://test.com'} +def test_localhost_cors_middleware_init_with_config(): + """Test that the middleware correctly reads permitted_cors_origins from global config.""" + mock_config = MagicMock() + mock_config.permitted_cors_origins = [ + 'https://example.com', + 'https://test.com', + ] + with patch( + 'openhands.server.middleware.get_global_config', return_value=mock_config ): app = FastAPI() middleware = LocalhostCORSMiddleware(app) - # Check that the origins were correctly parsed from the environment variable + # Check that the origins were correctly read from the config assert 'https://example.com' in middleware.allow_origins assert 'https://test.com' in middleware.allow_origins assert len(middleware.allow_origins) == 2 -def test_localhost_cors_middleware_init_without_env_var(): - """Test that the middleware works correctly without PERMITTED_CORS_ORIGINS environment variable.""" - with patch.dict(os.environ, {}, clear=True): +def test_localhost_cors_middleware_init_without_config(): + """Test that the middleware works correctly without permitted_cors_origins configured.""" + mock_config = MagicMock() + mock_config.permitted_cors_origins = [] + with patch( + 'openhands.server.middleware.get_global_config', return_value=mock_config + ): app = FastAPI() middleware = LocalhostCORSMiddleware(app) - # Check that allow_origins is empty when no environment variable is set + # Check that allow_origins is empty when no origins are configured assert middleware.allow_origins == () def test_localhost_cors_middleware_is_allowed_origin_localhost(app): """Test that localhost origins are allowed regardless of port when no specific origins are configured.""" - # Test without setting PERMITTED_CORS_ORIGINS to trigger localhost behavior - with patch.dict(os.environ, {}, clear=True): + mock_config = MagicMock() + mock_config.permitted_cors_origins = [] + with patch( + 'openhands.server.middleware.get_global_config', return_value=mock_config + ): app.add_middleware(LocalhostCORSMiddleware) client = TestClient(app) @@ -76,8 +87,11 @@ def test_localhost_cors_middleware_is_allowed_origin_localhost(app): def test_localhost_cors_middleware_is_allowed_origin_non_localhost(app): """Test that non-localhost origins follow the standard CORS rules.""" - # Set up the middleware with specific allowed origins - with patch.dict(os.environ, {'PERMITTED_CORS_ORIGINS': 'https://example.com'}): + mock_config = MagicMock() + mock_config.permitted_cors_origins = ['https://example.com'] + with patch( + 'openhands.server.middleware.get_global_config', return_value=mock_config + ): app.add_middleware(LocalhostCORSMiddleware) client = TestClient(app) @@ -95,7 +109,11 @@ def test_localhost_cors_middleware_is_allowed_origin_non_localhost(app): def test_localhost_cors_middleware_missing_origin(app): """Test behavior when Origin header is missing.""" - with patch.dict(os.environ, {}, clear=True): + mock_config = MagicMock() + mock_config.permitted_cors_origins = [] + with patch( + 'openhands.server.middleware.get_global_config', return_value=mock_config + ): app.add_middleware(LocalhostCORSMiddleware) client = TestClient(app) @@ -113,17 +131,22 @@ def test_localhost_cors_middleware_inheritance(): def test_localhost_cors_middleware_cors_parameters(): """Test that CORS parameters are set correctly in the middleware.""" - # We need to inspect the initialization parameters rather than attributes - # since CORSMiddleware doesn't expose these as attributes - with patch('fastapi.middleware.cors.CORSMiddleware.__init__') as mock_init: - mock_init.return_value = None - app = FastAPI() - LocalhostCORSMiddleware(app) + mock_config = MagicMock() + mock_config.permitted_cors_origins = [] + with patch( + 'openhands.server.middleware.get_global_config', return_value=mock_config + ): + # We need to inspect the initialization parameters rather than attributes + # since CORSMiddleware doesn't expose these as attributes + with patch('fastapi.middleware.cors.CORSMiddleware.__init__') as mock_init: + mock_init.return_value = None + app = FastAPI() + LocalhostCORSMiddleware(app) - # Check that the parent class was initialized with the correct parameters - mock_init.assert_called_once() - _, kwargs = mock_init.call_args + # Check that the parent class was initialized with the correct parameters + mock_init.assert_called_once() + _, kwargs = mock_init.call_args - assert kwargs['allow_credentials'] is True - assert kwargs['allow_methods'] == ['*'] - assert kwargs['allow_headers'] == ['*'] + assert kwargs['allow_credentials'] is True + assert kwargs['allow_methods'] == ['*'] + assert kwargs['allow_headers'] == ['*'] From 2d1e9fa35b58f22bbafc266fe61f045e0d781cbd Mon Sep 17 00:00:00 2001 From: aivong-openhands Date: Thu, 19 Mar 2026 10:05:30 -0500 Subject: [PATCH 78/92] Fix CVE-2026-33123: Update pypdf to 6.9.1 (#13473) Co-authored-by: OpenHands CVE Fix Bot --- enterprise/poetry.lock | 6 +++--- poetry.lock | 8 ++++---- pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index 1bb48f24c6..39ef61101d 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -11587,14 +11587,14 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pypdf" -version = "6.8.0" +version = "6.9.1" description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"}, - {file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"}, + {file = "pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f"}, + {file = "pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d"}, ] [package.extras] diff --git a/poetry.lock b/poetry.lock index bccd0eea80..9644ef383c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11564,14 +11564,14 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pypdf" -version = "6.8.0" +version = "6.9.1" description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"}, - {file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"}, + {file = "pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f"}, + {file = "pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d"}, ] [package.extras] @@ -14833,4 +14833,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api [metadata] lock-version = "2.1" python-versions = "^3.12,<3.14" -content-hash = "1a8151b36fb64667d1a2e83f38060841de15bd0284f18e8f58c6ee95095e933e" +content-hash = "1d1661870075ed85d87818cc3f3bd30bf23dcd00d1604be57f616f60b583c758" diff --git a/pyproject.toml b/pyproject.toml index 87609dbf9b..9595af0fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ dependencies = [ "pygithub>=2.5", "pyjwt>=2.12", "pylatexenc", - "pypdf>=6.7.2", + "pypdf>=6.9.1", "python-docx", "python-dotenv", "python-frontmatter>=1.1", @@ -224,7 +224,7 @@ python-docx = "*" bashlex = "^0.18" # Explicitly pinned packages for latest versions -pypdf = "^6.7.2" +pypdf = "^6.9.1" pillow = "^12.1.1" starlette = "^0.49.1" urllib3 = "^2.6.3" diff --git a/uv.lock b/uv.lock index 67c7965698..269ff03c0f 100644 --- a/uv.lock +++ b/uv.lock @@ -3846,7 +3846,7 @@ requires-dist = [ { name = "pygithub", specifier = ">=2.5" }, { name = "pyjwt", specifier = ">=2.12" }, { name = "pylatexenc" }, - { name = "pypdf", specifier = ">=6.7.2" }, + { name = "pypdf", specifier = ">=6.9.1" }, { name = "python-docx" }, { name = "python-dotenv" }, { name = "python-frontmatter", specifier = ">=1.1" }, @@ -7385,11 +7385,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.8.0" +version = "6.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/fb/dc2e8cb006e80b0020ed20d8649106fe4274e82d8e756ad3e24ade19c0df/pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d", size = 5311551, upload-time = "2026-03-17T10:46:07.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/75543fa802b86e72f87e9395440fe1a89a6d149887e3e55745715c3352ac/pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f", size = 333661, upload-time = "2026-03-17T10:46:06.286Z" }, ] [[package]] From 2224127ac305f353d2013655e71b591c33291056 Mon Sep 17 00:00:00 2001 From: chuckbutkus Date: Thu, 19 Mar 2026 11:14:48 -0400 Subject: [PATCH 79/92] Fix when budgets are None (#13482) Co-authored-by: openhands --- enterprise/storage/lite_llm_manager.py | 60 ++++-- .../tests/unit/test_lite_llm_manager.py | 192 ++++++++++++++++++ 2 files changed, 231 insertions(+), 21 deletions(-) diff --git a/enterprise/storage/lite_llm_manager.py b/enterprise/storage/lite_llm_manager.py index b515b7a7d9..d4e1aefd2c 100644 --- a/enterprise/storage/lite_llm_manager.py +++ b/enterprise/storage/lite_llm_manager.py @@ -589,20 +589,26 @@ class LiteLlmManager: if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None: logger.warning('LiteLLM API configuration not found') return + + json_data: dict[str, Any] = { + 'team_id': team_id, + 'team_alias': team_alias, + 'models': [], + 'spend': 0, + 'metadata': { + 'version': ORG_SETTINGS_VERSION, + 'model': get_default_litellm_model(), + }, + } + + if max_budget is not None: + json_data['max_budget'] = max_budget + response = await client.post( f'{LITE_LLM_API_URL}/team/new', - json={ - 'team_id': team_id, - 'team_alias': team_alias, - 'models': [], - 'max_budget': max_budget, # None disables budget enforcement - 'spend': 0, - 'metadata': { - 'version': ORG_SETTINGS_VERSION, - 'model': get_default_litellm_model(), - }, - }, + json=json_data, ) + # Team failed to create in litellm - this is an unforseen error state... if not response.is_success: if ( @@ -1040,14 +1046,20 @@ class LiteLlmManager: if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None: logger.warning('LiteLLM API configuration not found') return + + json_data: dict[str, Any] = { + 'team_id': team_id, + 'member': {'user_id': keycloak_user_id, 'role': 'user'}, + } + + if max_budget is not None: + json_data['max_budget_in_team'] = max_budget + response = await client.post( f'{LITE_LLM_API_URL}/team/member_add', - json={ - 'team_id': team_id, - 'member': {'user_id': keycloak_user_id, 'role': 'user'}, - 'max_budget_in_team': max_budget, # None disables budget enforcement - }, + json=json_data, ) + # Failed to add user to team - this is an unforseen error state... if not response.is_success: if ( @@ -1129,14 +1141,20 @@ class LiteLlmManager: if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None: logger.warning('LiteLLM API configuration not found') return + + json_data: dict[str, Any] = { + 'team_id': team_id, + 'user_id': keycloak_user_id, + } + + if max_budget is not None: + json_data['max_budget_in_team'] = max_budget + response = await client.post( f'{LITE_LLM_API_URL}/team/member_update', - json={ - 'team_id': team_id, - 'user_id': keycloak_user_id, - 'max_budget_in_team': max_budget, # None disables budget enforcement - }, + json=json_data, ) + # Failed to update user in team - this is an unforseen error state... if not response.is_success: logger.error( diff --git a/enterprise/tests/unit/test_lite_llm_manager.py b/enterprise/tests/unit/test_lite_llm_manager.py index 3da159421d..ffd964b77f 100644 --- a/enterprise/tests/unit/test_lite_llm_manager.py +++ b/enterprise/tests/unit/test_lite_llm_manager.py @@ -2384,3 +2384,195 @@ class TestVerifyExistingKey: openhands_type=True, ) assert result is False + + +class TestBudgetPayloadHandling: + """Test cases for budget field handling in API payloads. + + These tests verify that when max_budget is None, the budget field is NOT + included in the JSON payload (which tells LiteLLM to disable budget + enforcement), and when max_budget has a value, it IS included. + """ + + @pytest.mark.asyncio + async def test_create_team_excludes_max_budget_when_none(self): + """Test that _create_team does NOT include max_budget when it is None.""" + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_response = MagicMock() + mock_response.is_success = True + mock_response.status_code = 200 + mock_client.post.return_value = mock_response + + with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'): + with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'): + await LiteLlmManager._create_team( + mock_client, + team_alias='test-team', + team_id='test-team-id', + max_budget=None, # None = no budget limit + ) + + # Verify the call was made + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + + # Verify URL + assert call_args[0][0] == 'http://test.com/team/new' + + # Verify that max_budget is NOT in the JSON payload + json_payload = call_args[1]['json'] + assert 'max_budget' not in json_payload, ( + 'max_budget should NOT be in payload when None ' + '(omitting it tells LiteLLM to disable budget enforcement)' + ) + + @pytest.mark.asyncio + async def test_create_team_includes_max_budget_when_set(self): + """Test that _create_team includes max_budget when it has a value.""" + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_response = MagicMock() + mock_response.is_success = True + mock_response.status_code = 200 + mock_client.post.return_value = mock_response + + with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'): + with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'): + await LiteLlmManager._create_team( + mock_client, + team_alias='test-team', + team_id='test-team-id', + max_budget=100.0, # Explicit budget limit + ) + + # Verify the call was made + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + + # Verify that max_budget IS in the JSON payload with the correct value + json_payload = call_args[1]['json'] + assert ( + 'max_budget' in json_payload + ), 'max_budget should be in payload when set to a value' + assert json_payload['max_budget'] == 100.0 + + @pytest.mark.asyncio + async def test_add_user_to_team_excludes_max_budget_when_none(self): + """Test that _add_user_to_team does NOT include max_budget_in_team when None.""" + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_response = MagicMock() + mock_response.is_success = True + mock_response.status_code = 200 + mock_client.post.return_value = mock_response + + with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'): + with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'): + await LiteLlmManager._add_user_to_team( + mock_client, + keycloak_user_id='test-user-id', + team_id='test-team-id', + max_budget=None, # None = no budget limit + ) + + # Verify the call was made + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + + # Verify URL + assert call_args[0][0] == 'http://test.com/team/member_add' + + # Verify that max_budget_in_team is NOT in the JSON payload + json_payload = call_args[1]['json'] + assert 'max_budget_in_team' not in json_payload, ( + 'max_budget_in_team should NOT be in payload when None ' + '(omitting it tells LiteLLM to disable budget enforcement)' + ) + + @pytest.mark.asyncio + async def test_add_user_to_team_includes_max_budget_when_set(self): + """Test that _add_user_to_team includes max_budget_in_team when set.""" + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_response = MagicMock() + mock_response.is_success = True + mock_response.status_code = 200 + mock_client.post.return_value = mock_response + + with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'): + with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'): + await LiteLlmManager._add_user_to_team( + mock_client, + keycloak_user_id='test-user-id', + team_id='test-team-id', + max_budget=50.0, # Explicit budget limit + ) + + # Verify the call was made + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + + # Verify that max_budget_in_team IS in the JSON payload + json_payload = call_args[1]['json'] + assert ( + 'max_budget_in_team' in json_payload + ), 'max_budget_in_team should be in payload when set to a value' + assert json_payload['max_budget_in_team'] == 50.0 + + @pytest.mark.asyncio + async def test_update_user_in_team_excludes_max_budget_when_none(self): + """Test that _update_user_in_team does NOT include max_budget_in_team when None.""" + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_response = MagicMock() + mock_response.is_success = True + mock_response.status_code = 200 + mock_client.post.return_value = mock_response + + with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'): + with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'): + await LiteLlmManager._update_user_in_team( + mock_client, + keycloak_user_id='test-user-id', + team_id='test-team-id', + max_budget=None, # None = no budget limit + ) + + # Verify the call was made + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + + # Verify URL + assert call_args[0][0] == 'http://test.com/team/member_update' + + # Verify that max_budget_in_team is NOT in the JSON payload + json_payload = call_args[1]['json'] + assert 'max_budget_in_team' not in json_payload, ( + 'max_budget_in_team should NOT be in payload when None ' + '(omitting it tells LiteLLM to disable budget enforcement)' + ) + + @pytest.mark.asyncio + async def test_update_user_in_team_includes_max_budget_when_set(self): + """Test that _update_user_in_team includes max_budget_in_team when set.""" + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_response = MagicMock() + mock_response.is_success = True + mock_response.status_code = 200 + mock_client.post.return_value = mock_response + + with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'): + with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'): + await LiteLlmManager._update_user_in_team( + mock_client, + keycloak_user_id='test-user-id', + team_id='test-team-id', + max_budget=75.0, # Explicit budget limit + ) + + # Verify the call was made + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + + # Verify that max_budget_in_team IS in the JSON payload + json_payload = call_args[1]['json'] + assert ( + 'max_budget_in_team' in json_payload + ), 'max_budget_in_team should be in payload when set to a value' + assert json_payload['max_budget_in_team'] == 75.0 From 120fd7516a4e4eb9b4aa808bfaf53eb3072558e3 Mon Sep 17 00:00:00 2001 From: Chris Bagwell Date: Thu, 19 Mar 2026 10:33:01 -0500 Subject: [PATCH 80/92] Fix: Prevent auto-logout on 401 errors in oss mode (#13466) --- .../features/user/user-context-menu.test.tsx | 40 +++++++++++++++++-- .../features/user/user-context-menu.tsx | 17 ++++---- frontend/src/hooks/query/use-git-user.ts | 7 ++-- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/frontend/__tests__/components/features/user/user-context-menu.test.tsx b/frontend/__tests__/components/features/user/user-context-menu.test.tsx index 635f66e645..07895f547c 100644 --- a/frontend/__tests__/components/features/user/user-context-menu.test.tsx +++ b/frontend/__tests__/components/features/user/user-context-menu.test.tsx @@ -156,11 +156,19 @@ describe("UserContextMenu", () => { useSelectedOrganizationStore.setState({ organizationId: null }); }); - it("should render the default context items for a user", () => { + it("should render the default context items for a user", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ app_mode: "saas" }), + ); + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); screen.getByTestId("org-selector"); - screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); + + // Wait for config to load so logout button appears + await waitFor(() => { + expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument(); + }); expect( screen.queryByText("ORG$INVITE_ORG_MEMBERS"), @@ -304,6 +312,20 @@ describe("UserContextMenu", () => { screen.queryByText("Organization Members"), ).not.toBeInTheDocument(); }); + + it("should not display logout button in OSS mode", async () => { + renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); + + // Wait for the config to load + await waitFor(() => { + expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument(); + }); + + // Verify logout button is NOT rendered in OSS mode + expect( + screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"), + ).not.toBeInTheDocument(); + }); }); describe("HIDE_LLM_SETTINGS feature flag", () => { @@ -382,10 +404,15 @@ describe("UserContextMenu", () => { }); it("should call the logout handler when Logout is clicked", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ app_mode: "saas" }), + ); + const logoutSpy = vi.spyOn(AuthService, "logout"); renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn }); - const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); + // Wait for config to load so logout button appears + const logoutButton = await screen.findByText("ACCOUNT_SETTINGS$LOGOUT"); await userEvent.click(logoutButton); expect(logoutSpy).toHaveBeenCalledOnce(); @@ -488,6 +515,10 @@ describe("UserContextMenu", () => { }); it("should call the onClose handler after each action", async () => { + vi.spyOn(OptionService, "getConfig").mockResolvedValue( + createMockWebClientConfig({ app_mode: "saas" }), + ); + // Mock a team org so org management buttons are visible vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ items: [MOCK_TEAM_ORG_ACME], @@ -497,7 +528,8 @@ describe("UserContextMenu", () => { const onCloseMock = vi.fn(); renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn }); - const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); + // Wait for config to load so logout button appears + const logoutButton = await screen.findByText("ACCOUNT_SETTINGS$LOGOUT"); await userEvent.click(logoutButton); expect(onCloseMock).toHaveBeenCalledTimes(1); diff --git a/frontend/src/components/features/user/user-context-menu.tsx b/frontend/src/components/features/user/user-context-menu.tsx index b9094cc6d3..424dc7c0ec 100644 --- a/frontend/src/components/features/user/user-context-menu.tsx +++ b/frontend/src/components/features/user/user-context-menu.tsx @@ -156,13 +156,16 @@ export function UserContextMenu({ {t(I18nKey.SIDEBAR$DOCS)} - - - {t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)} - + {/* Only show logout in saas mode - oss mode has no session to invalidate */} + {isSaasMode && ( + + + {t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)} + + )}
diff --git a/frontend/src/hooks/query/use-git-user.ts b/frontend/src/hooks/query/use-git-user.ts index 971999f25c..a239b2d18a 100644 --- a/frontend/src/hooks/query/use-git-user.ts +++ b/frontend/src/hooks/query/use-git-user.ts @@ -35,13 +35,14 @@ export const useGitUser = () => { } }, [user.data]); - // If we get a 401 here, it means that the integration tokens need to be + // In saas mode, a 401 means that the integration tokens need to be // refreshed. Since this happens at login, we log out. + // In oss mode, skip auto-logout since there's no token refresh mechanism React.useEffect(() => { - if (user?.error?.response?.status === 401) { + if (user?.error?.response?.status === 401 && config?.app_mode === "saas") { logout.mutate(); } - }, [user.status]); + }, [user.status, config?.app_mode]); return user; }; From 04330898b6fca7dbe642e8221076783b94b1754d Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:12:38 +0700 Subject: [PATCH 81/92] refactor(frontend): add delay before closing user context menu (#13491) --- .../components/user-actions.test.tsx | 139 ++++++++++++++++-- .../features/sidebar/user-actions.tsx | 30 +++- 2 files changed, 151 insertions(+), 18 deletions(-) diff --git a/frontend/__tests__/components/user-actions.test.tsx b/frontend/__tests__/components/user-actions.test.tsx index 936586168d..4a8a42d1be 100644 --- a/frontend/__tests__/components/user-actions.test.tsx +++ b/frontend/__tests__/components/user-actions.test.tsx @@ -1,13 +1,16 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; import { describe, expect, it, vi, afterEach, beforeEach, test } from "vitest"; import userEvent from "@testing-library/user-event"; import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; -import { MemoryRouter } from "react-router"; +import { MemoryRouter, createRoutesStub } from "react-router"; import { ReactElement } from "react"; +import { http, HttpResponse } from "msw"; import { UserActions } from "#/components/features/sidebar/user-actions"; import { organizationService } from "#/api/organization-service/organization-service.api"; import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers"; import { useSelectedOrganizationStore } from "#/stores/selected-organization-store"; +import { server } from "#/mocks/node"; +import { createMockWebClientConfig } from "#/mocks/settings-handlers"; import { renderWithProviders } from "../../test-utils"; vi.mock("react-router", async (importActual) => ({ @@ -59,6 +62,20 @@ const renderUserActions = (props = { hasAvatar: true }) => { ); }; +// RouterStub and render helper for menu close delay tests +const RouterStubForMenuCloseDelay = createRoutesStub([ + { + path: "/", + Component: () => ( + + ), + }, +]); + +const renderUserActionsForMenuCloseDelay = () => { + return renderWithProviders(); +}; + // Create mocks for all the hooks we need const useIsAuthedMock = vi .fn() @@ -347,7 +364,7 @@ describe("UserActions", () => { expect(contextMenu).toBeVisible(); }); - it("should have pointer-events-none on hover bridge pseudo-element to allow menu item clicks", async () => { + it("should use state-based visibility for hover behavior instead of CSS pseudo-element", async () => { renderUserActions(); const userActions = screen.getByTestId("user-actions"); @@ -356,19 +373,17 @@ describe("UserActions", () => { const contextMenu = screen.getByTestId("user-context-menu"); const hoverBridgeContainer = contextMenu.parentElement; - // The hover bridge uses a ::before pseudo-element for diagonal mouse movement - // This pseudo-element MUST have pointer-events-none to allow clicks through to menu items - // The class should include "before:pointer-events-none" to prevent the hover bridge from blocking clicks - expect(hoverBridgeContainer?.className).toContain( - "before:pointer-events-none", - ); + // The component uses state-based visibility with a 500ms delay for diagonal mouse movement + // When visible, the container should have opacity-100 and pointer-events-auto + expect(hoverBridgeContainer?.className).toContain("opacity-100"); + expect(hoverBridgeContainer?.className).toContain("pointer-events-auto"); }); describe("Org selector dropdown state reset when context menu hides", () => { // These tests verify that the org selector dropdown resets its internal // state (search text, open/closed) when the context menu hides and - // reappears. Without this, stale state persists because the context - // menu is hidden via CSS (opacity/pointer-events) rather than unmounted. + // reappears. The component uses a 500ms delay before hiding (to support + // diagonal mouse movement). beforeEach(() => { vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({ @@ -400,8 +415,22 @@ describe("UserActions", () => { await user.type(input, "search text"); expect(input).toHaveValue("search text"); - // Unhover to hide context menu, then hover again + // Unhover to trigger hide timeout, then wait for the 500ms delay to complete await user.unhover(userActions); + + // Wait for the 500ms hide delay to complete and menu to actually hide + await waitFor( + () => { + // The menu resets when it actually hides (after 500ms delay) + // After hiding, hovering again should show a fresh menu + }, + { timeout: 600 }, + ); + + // Wait a bit more for the timeout to fire + await new Promise((resolve) => setTimeout(resolve, 550)); + + // Now hover again to show the menu await user.hover(userActions); // Org selector should be reset — showing selected org name, not search text @@ -434,8 +463,13 @@ describe("UserActions", () => { await user.type(input, "Acme"); expect(input).toHaveValue("Acme"); - // Unhover to hide context menu, then hover again + // Unhover to trigger hide timeout await user.unhover(userActions); + + // Wait for the 500ms hide delay to complete + await new Promise((resolve) => setTimeout(resolve, 550)); + + // Now hover again to show the menu await user.hover(userActions); // Wait for fresh component with org data @@ -454,4 +488,83 @@ describe("UserActions", () => { expect(screen.queryAllByRole("option")).toHaveLength(0); }); }); + + describe("menu close delay", () => { + beforeEach(() => { + vi.useFakeTimers(); + useSelectedOrganizationStore.setState({ organizationId: "1" }); + + // Mock config to return SaaS mode so useShouldShowUserFeatures returns true + server.use( + http.get("/api/v1/web-client/config", () => + HttpResponse.json(createMockWebClientConfig({ app_mode: "saas" })), + ), + ); + }); + + afterEach(() => { + vi.useRealTimers(); + server.resetHandlers(); + }); + + it("should keep menu visible when mouse leaves and re-enters within 500ms", async () => { + // Arrange - render and wait for queries to settle + renderUserActionsForMenuCloseDelay(); + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const userActions = screen.getByTestId("user-actions"); + + // Act - open menu + await act(async () => { + fireEvent.mouseEnter(userActions); + }); + + // Assert - menu is visible + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); + + // Act - leave and re-enter within 500ms + await act(async () => { + fireEvent.mouseLeave(userActions); + await vi.advanceTimersByTimeAsync(200); + fireEvent.mouseEnter(userActions); + }); + + // Assert - menu should still be visible after waiting (pending close was cancelled) + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); + }); + + it("should not close menu before 500ms delay when mouse leaves", async () => { + // Arrange - render and wait for queries to settle + renderUserActionsForMenuCloseDelay(); + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const userActions = screen.getByTestId("user-actions"); + + // Act - open menu + await act(async () => { + fireEvent.mouseEnter(userActions); + }); + + // Assert - menu is visible + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); + + // Act - leave without re-entering, but check before timeout expires + await act(async () => { + fireEvent.mouseLeave(userActions); + await vi.advanceTimersByTimeAsync(400); // Before the 500ms delay + }); + + // Assert - menu should still be visible (delay hasn't expired yet) + // Note: The menu is always in DOM but with opacity-0 when closed. + // This test verifies the state hasn't changed yet (delay is working). + expect(screen.getByTestId("user-context-menu")).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/features/sidebar/user-actions.tsx b/frontend/src/components/features/sidebar/user-actions.tsx index 3620663789..2c715e4c2c 100644 --- a/frontend/src/components/features/sidebar/user-actions.tsx +++ b/frontend/src/components/features/sidebar/user-actions.tsx @@ -22,20 +22,43 @@ export function UserActions({ user, isLoading }: UserActionsProps) { const [menuResetCount, setMenuResetCount] = React.useState(0); const [inviteMemberModalIsOpen, setInviteMemberModalIsOpen] = React.useState(false); + const hideTimeoutRef = React.useRef(null); // Use the shared hook to determine if user actions should be shown const shouldShowUserActions = useShouldShowUserFeatures(); + // Clean up timeout on unmount + React.useEffect( + () => () => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + }, + [], + ); + const showAccountMenu = () => { + // Cancel any pending hide to allow diagonal mouse movement to menu + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } setAccountContextMenuIsVisible(true); }; const hideAccountMenu = () => { - setAccountContextMenuIsVisible(false); - setMenuResetCount((c) => c + 1); + // Delay hiding to allow diagonal mouse movement to menu + hideTimeoutRef.current = window.setTimeout(() => { + setAccountContextMenuIsVisible(false); + setMenuResetCount((c) => c + 1); + }, 500); }; const closeAccountMenu = () => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } if (accountContextMenuIsVisible) { setAccountContextMenuIsVisible(false); setMenuResetCount((c) => c + 1); @@ -61,9 +84,6 @@ export function UserActions({ user, isLoading }: UserActionsProps) { className={cn( "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto", accountContextMenuIsVisible && "opacity-100 pointer-events-auto", - // Invisible hover bridge: extends hover zone to create a "safe corridor" - // for diagonal mouse movement to the menu (only active when menu is visible) - "group-hover:before:content-[''] group-hover:before:block group-hover:before:absolute group-hover:before:inset-[-320px] group-hover:before:z-50 before:pointer-events-none", )} > Date: Fri, 20 Mar 2026 00:12:48 +0700 Subject: [PATCH 82/92] refactor(frontend): extract AddCreditsModal into separate component file (#13490) --- .../features/org/add-credits-modal.test.tsx | 351 ++++++++++++++++++ frontend/__tests__/routes/manage-org.test.tsx | 299 --------------- .../features/org/add-credits-modal.tsx | 103 +++++ frontend/src/routes/manage-org.tsx | 101 +---- 4 files changed, 455 insertions(+), 399 deletions(-) create mode 100644 frontend/__tests__/components/features/org/add-credits-modal.test.tsx create mode 100644 frontend/src/components/features/org/add-credits-modal.tsx diff --git a/frontend/__tests__/components/features/org/add-credits-modal.test.tsx b/frontend/__tests__/components/features/org/add-credits-modal.test.tsx new file mode 100644 index 0000000000..1c049aedcd --- /dev/null +++ b/frontend/__tests__/components/features/org/add-credits-modal.test.tsx @@ -0,0 +1,351 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "test-utils"; +import { AddCreditsModal } from "#/components/features/org/add-credits-modal"; +import BillingService from "#/api/billing-service/billing-service.api"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + changeLanguage: vi.fn(), + }, + }), +})); + +describe("AddCreditsModal", () => { + const onCloseMock = vi.fn(); + + const renderModal = () => { + const user = userEvent.setup(); + renderWithProviders(); + return { user }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Rendering", () => { + it("should render the form with correct elements", () => { + renderModal(); + + expect(screen.getByTestId("add-credits-form")).toBeInTheDocument(); + expect(screen.getByTestId("amount-input")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /ORG\$NEXT/i })).toBeInTheDocument(); + }); + + it("should display the title", () => { + renderModal(); + + expect(screen.getByText("ORG$ADD_CREDITS")).toBeInTheDocument(); + }); + }); + + describe("Button State Management", () => { + it("should enable submit button initially when modal opens", () => { + renderModal(); + + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + expect(nextButton).not.toBeDisabled(); + }); + + it("should enable submit button when input contains invalid value", async () => { + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "-50"); + + expect(nextButton).not.toBeDisabled(); + }); + + it("should enable submit button when input contains valid value", async () => { + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "100"); + + expect(nextButton).not.toBeDisabled(); + }); + + it("should enable submit button after validation error is shown", async () => { + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "9"); + await user.click(nextButton); + + await waitFor(() => { + expect(screen.getByTestId("amount-error")).toBeInTheDocument(); + }); + + expect(nextButton).not.toBeDisabled(); + }); + }); + + describe("Input Attributes & Placeholder", () => { + it("should have min attribute set to 10", () => { + renderModal(); + + const amountInput = screen.getByTestId("amount-input"); + expect(amountInput).toHaveAttribute("min", "10"); + }); + + it("should have max attribute set to 25000", () => { + renderModal(); + + const amountInput = screen.getByTestId("amount-input"); + expect(amountInput).toHaveAttribute("max", "25000"); + }); + + it("should have step attribute set to 1", () => { + renderModal(); + + const amountInput = screen.getByTestId("amount-input"); + expect(amountInput).toHaveAttribute("step", "1"); + }); + }); + + describe("Error Message Display", () => { + it("should not display error message initially when modal opens", () => { + renderModal(); + + const errorMessage = screen.queryByTestId("amount-error"); + expect(errorMessage).not.toBeInTheDocument(); + }); + + it("should display error message after submitting amount above maximum", async () => { + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "25001"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT"); + }); + }); + + it("should display error message after submitting decimal value", async () => { + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "50.5"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER"); + }); + }); + + it("should display error message after submitting amount below minimum", async () => { + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "9"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT"); + }); + }); + + it("should display error message after submitting negative amount", async () => { + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "-50"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT"); + }); + }); + + it("should replace error message when submitting different invalid value", async () => { + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "9"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT"); + }); + + await user.clear(amountInput); + await user.type(amountInput, "25001"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MAXIMUM_AMOUNT"); + }); + }); + }); + + describe("Form Submission Behavior", () => { + it("should prevent submission when amount is invalid", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "9"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT"); + }); + }); + + it("should call createCheckoutSession with correct amount when valid", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "1000"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000); + const errorMessage = screen.queryByTestId("amount-error"); + expect(errorMessage).not.toBeInTheDocument(); + }); + + it("should not call createCheckoutSession when validation fails", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "-50"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_NEGATIVE_AMOUNT"); + }); + }); + + it("should close modal on successful submission", async () => { + vi.spyOn(BillingService, "createCheckoutSession").mockResolvedValue( + "https://checkout.stripe.com/test-session", + ); + + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "1000"); + await user.click(nextButton); + + await waitFor(() => { + expect(onCloseMock).toHaveBeenCalled(); + }); + }); + + it("should allow API call when validation passes and clear any previous errors", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + // First submit invalid value + await user.type(amountInput, "9"); + await user.click(nextButton); + + await waitFor(() => { + expect(screen.getByTestId("amount-error")).toBeInTheDocument(); + }); + + // Then submit valid value + await user.clear(amountInput); + await user.type(amountInput, "100"); + await user.click(nextButton); + + expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100); + const errorMessage = screen.queryByTestId("amount-error"); + expect(errorMessage).not.toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("should handle zero value correctly", async () => { + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + await user.type(amountInput, "0"); + await user.click(nextButton); + + await waitFor(() => { + const errorMessage = screen.getByTestId("amount-error"); + expect(errorMessage).toHaveTextContent("PAYMENT$ERROR_MINIMUM_AMOUNT"); + }); + }); + + it("should handle whitespace-only input correctly", async () => { + const createCheckoutSessionSpy = vi.spyOn( + BillingService, + "createCheckoutSession", + ); + const { user } = renderModal(); + const amountInput = screen.getByTestId("amount-input"); + const nextButton = screen.getByRole("button", { name: /ORG\$NEXT/i }); + + // Number inputs typically don't accept spaces, but test the behavior + await user.type(amountInput, " "); + await user.click(nextButton); + + // Should not call API (empty/invalid input) + expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); + }); + }); + + describe("Modal Interaction", () => { + it("should call onClose when cancel button is clicked", async () => { + const { user } = renderModal(); + + const cancelButton = screen.getByRole("button", { name: /close/i }); + await user.click(cancelButton); + + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/frontend/__tests__/routes/manage-org.test.tsx b/frontend/__tests__/routes/manage-org.test.tsx index 390b10fc43..8f5cc137be 100644 --- a/frontend/__tests__/routes/manage-org.test.tsx +++ b/frontend/__tests__/routes/manage-org.test.tsx @@ -283,305 +283,6 @@ describe("Manage Org Route", () => { expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); }); - describe("AddCreditsModal", () => { - const openAddCreditsModal = async () => { - const user = userEvent.setup(); - renderManageOrg(); - await screen.findByTestId("manage-org-screen"); - - await selectOrganization({ orgIndex: 0 }); // user is owner in org 1 - - const addCreditsButton = await waitFor(() => screen.getByText(/add/i)); - await user.click(addCreditsButton); - - const addCreditsForm = screen.getByTestId("add-credits-form"); - expect(addCreditsForm).toBeInTheDocument(); - - return { user, addCreditsForm }; - }; - - describe("Button State Management", () => { - it("should enable submit button initially when modal opens", async () => { - await openAddCreditsModal(); - - const nextButton = screen.getByRole("button", { name: /next/i }); - expect(nextButton).not.toBeDisabled(); - }); - - it("should enable submit button when input contains invalid value", async () => { - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - await user.type(amountInput, "-50"); - - expect(nextButton).not.toBeDisabled(); - }); - - it("should enable submit button when input contains valid value", async () => { - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - await user.type(amountInput, "100"); - - expect(nextButton).not.toBeDisabled(); - }); - - it("should enable submit button after validation error is shown", async () => { - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - await user.type(amountInput, "9"); - await user.click(nextButton); - - await waitFor(() => { - expect(screen.getByTestId("amount-error")).toBeInTheDocument(); - }); - - expect(nextButton).not.toBeDisabled(); - }); - }); - - describe("Input Attributes & Placeholder", () => { - it("should have min attribute set to 10", async () => { - await openAddCreditsModal(); - - const amountInput = screen.getByTestId("amount-input"); - expect(amountInput).toHaveAttribute("min", "10"); - }); - - it("should have max attribute set to 25000", async () => { - await openAddCreditsModal(); - - const amountInput = screen.getByTestId("amount-input"); - expect(amountInput).toHaveAttribute("max", "25000"); - }); - - it("should have step attribute set to 1", async () => { - await openAddCreditsModal(); - - const amountInput = screen.getByTestId("amount-input"); - expect(amountInput).toHaveAttribute("step", "1"); - }); - }); - - describe("Error Message Display", () => { - it("should not display error message initially when modal opens", async () => { - await openAddCreditsModal(); - - const errorMessage = screen.queryByTestId("amount-error"); - expect(errorMessage).not.toBeInTheDocument(); - }); - - it("should display error message after submitting amount above maximum", async () => { - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - await user.type(amountInput, "25001"); - await user.click(nextButton); - - await waitFor(() => { - const errorMessage = screen.getByTestId("amount-error"); - expect(errorMessage).toHaveTextContent( - "PAYMENT$ERROR_MAXIMUM_AMOUNT", - ); - }); - }); - - it("should display error message after submitting decimal value", async () => { - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - await user.type(amountInput, "50.5"); - await user.click(nextButton); - - await waitFor(() => { - const errorMessage = screen.getByTestId("amount-error"); - expect(errorMessage).toHaveTextContent( - "PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER", - ); - }); - }); - - it("should replace error message when submitting different invalid value", async () => { - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - await user.type(amountInput, "9"); - await user.click(nextButton); - - await waitFor(() => { - const errorMessage = screen.getByTestId("amount-error"); - expect(errorMessage).toHaveTextContent( - "PAYMENT$ERROR_MINIMUM_AMOUNT", - ); - }); - - await user.clear(amountInput); - await user.type(amountInput, "25001"); - await user.click(nextButton); - - await waitFor(() => { - const errorMessage = screen.getByTestId("amount-error"); - expect(errorMessage).toHaveTextContent( - "PAYMENT$ERROR_MAXIMUM_AMOUNT", - ); - }); - }); - }); - - describe("Form Submission Behavior", () => { - it("should prevent submission when amount is invalid", async () => { - const createCheckoutSessionSpy = vi.spyOn( - BillingService, - "createCheckoutSession", - ); - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - await user.type(amountInput, "9"); - await user.click(nextButton); - - expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); - await waitFor(() => { - const errorMessage = screen.getByTestId("amount-error"); - expect(errorMessage).toHaveTextContent( - "PAYMENT$ERROR_MINIMUM_AMOUNT", - ); - }); - }); - - it("should call createCheckoutSession with correct amount when valid", async () => { - const createCheckoutSessionSpy = vi.spyOn( - BillingService, - "createCheckoutSession", - ); - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - await user.type(amountInput, "1000"); - await user.click(nextButton); - - expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000); - const errorMessage = screen.queryByTestId("amount-error"); - expect(errorMessage).not.toBeInTheDocument(); - }); - - it("should not call createCheckoutSession when validation fails", async () => { - const createCheckoutSessionSpy = vi.spyOn( - BillingService, - "createCheckoutSession", - ); - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - await user.type(amountInput, "-50"); - await user.click(nextButton); - - // Verify mutation was not called - expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); - await waitFor(() => { - const errorMessage = screen.getByTestId("amount-error"); - expect(errorMessage).toHaveTextContent( - "PAYMENT$ERROR_NEGATIVE_AMOUNT", - ); - }); - }); - - it("should close modal on successful submission", async () => { - const createCheckoutSessionSpy = vi - .spyOn(BillingService, "createCheckoutSession") - .mockResolvedValue("https://checkout.stripe.com/test-session"); - - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - await user.type(amountInput, "1000"); - await user.click(nextButton); - - expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000); - - await waitFor(() => { - expect( - screen.queryByTestId("add-credits-form"), - ).not.toBeInTheDocument(); - }); - }); - - it("should allow API call when validation passes and clear any previous errors", async () => { - const createCheckoutSessionSpy = vi.spyOn( - BillingService, - "createCheckoutSession", - ); - - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - // First submit invalid value - await user.type(amountInput, "9"); - await user.click(nextButton); - - await waitFor(() => { - expect(screen.getByTestId("amount-error")).toBeInTheDocument(); - }); - - // Then submit valid value - await user.clear(amountInput); - await user.type(amountInput, "100"); - await user.click(nextButton); - - expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100); - const errorMessage = screen.queryByTestId("amount-error"); - expect(errorMessage).not.toBeInTheDocument(); - }); - }); - - describe("Edge Cases", () => { - it("should handle zero value correctly", async () => { - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - await user.type(amountInput, "0"); - await user.click(nextButton); - - await waitFor(() => { - const errorMessage = screen.getByTestId("amount-error"); - expect(errorMessage).toHaveTextContent( - "PAYMENT$ERROR_MINIMUM_AMOUNT", - ); - }); - }); - - it("should handle whitespace-only input correctly", async () => { - const createCheckoutSessionSpy = vi.spyOn( - BillingService, - "createCheckoutSession", - ); - const { user } = await openAddCreditsModal(); - const amountInput = screen.getByTestId("amount-input"); - const nextButton = screen.getByRole("button", { name: /next/i }); - - // Number inputs typically don't accept spaces, but test the behavior - await user.type(amountInput, " "); - await user.click(nextButton); - - // Should not call API (empty/invalid input) - expect(createCheckoutSessionSpy).not.toHaveBeenCalled(); - }); - }); - }); - it("should show add credits option for ADMIN role", async () => { renderManageOrg(); await screen.findByTestId("manage-org-screen"); diff --git a/frontend/src/components/features/org/add-credits-modal.tsx b/frontend/src/components/features/org/add-credits-modal.tsx new file mode 100644 index 0000000000..78ef6519b2 --- /dev/null +++ b/frontend/src/components/features/org/add-credits-modal.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalButtonGroup } from "#/components/shared/modals/modal-button-group"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { I18nKey } from "#/i18n/declaration"; +import { amountIsValid } from "#/utils/amount-is-valid"; + +interface AddCreditsModalProps { + onClose: () => void; +} + +export function AddCreditsModal({ onClose }: AddCreditsModalProps) { + const { t } = useTranslation(); + const { mutate: addBalance } = useCreateStripeCheckoutSession(); + + const [inputValue, setInputValue] = React.useState(""); + const [errorMessage, setErrorMessage] = React.useState(null); + + const getErrorMessage = (value: string): string | null => { + if (!value.trim()) return null; + + const numValue = parseInt(value, 10); + if (Number.isNaN(numValue)) { + return t(I18nKey.PAYMENT$ERROR_INVALID_NUMBER); + } + if (numValue < 0) { + return t(I18nKey.PAYMENT$ERROR_NEGATIVE_AMOUNT); + } + if (numValue < 10) { + return t(I18nKey.PAYMENT$ERROR_MINIMUM_AMOUNT); + } + if (numValue > 25000) { + return t(I18nKey.PAYMENT$ERROR_MAXIMUM_AMOUNT); + } + if (numValue !== parseFloat(value)) { + return t(I18nKey.PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER); + } + return null; + }; + + const formAction = (formData: FormData) => { + const amount = formData.get("amount")?.toString(); + + if (amount?.trim()) { + if (!amountIsValid(amount)) { + const error = getErrorMessage(amount); + setErrorMessage(error || "Invalid amount"); + return; + } + + const intValue = parseInt(amount, 10); + + addBalance({ amount: intValue }, { onSuccess: onClose }); + + setErrorMessage(null); + } + }; + + const handleAmountInputChange = (value: string) => { + setInputValue(value); + setErrorMessage(null); + }; + + return ( + +
+

{t(I18nKey.ORG$ADD_CREDITS)}

+
+ handleAmountInputChange(value)} + className="w-full" + /> + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ + + +
+ ); +} diff --git a/frontend/src/routes/manage-org.tsx b/frontend/src/routes/manage-org.tsx index cff5429344..cc14274923 100644 --- a/frontend/src/routes/manage-org.tsx +++ b/frontend/src/routes/manage-org.tsx @@ -1,14 +1,9 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session"; import { useOrganization } from "#/hooks/query/use-organization"; -import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; -import { ModalButtonGroup } from "#/components/shared/modals/modal-button-group"; -import { SettingsInput } from "#/components/features/settings/settings-input"; import { useMe } from "#/hooks/query/use-me"; import { useConfig } from "#/hooks/query/use-config"; import { I18nKey } from "#/i18n/declaration"; -import { amountIsValid } from "#/utils/amount-is-valid"; import { CreditsChip } from "#/ui/credits-chip"; import { InteractiveChip } from "#/ui/interactive-chip"; import { usePermission } from "#/hooks/organizations/use-permissions"; @@ -16,104 +11,10 @@ import { createPermissionGuard } from "#/utils/org/permission-guard"; import { isBillingHidden } from "#/utils/org/billing-visibility"; import { DeleteOrgConfirmationModal } from "#/components/features/org/delete-org-confirmation-modal"; import { ChangeOrgNameModal } from "#/components/features/org/change-org-name-modal"; +import { AddCreditsModal } from "#/components/features/org/add-credits-modal"; import { useBalance } from "#/hooks/query/use-balance"; import { cn } from "#/utils/utils"; -interface AddCreditsModalProps { - onClose: () => void; -} - -function AddCreditsModal({ onClose }: AddCreditsModalProps) { - const { t } = useTranslation(); - const { mutate: addBalance } = useCreateStripeCheckoutSession(); - - const [inputValue, setInputValue] = React.useState(""); - const [errorMessage, setErrorMessage] = React.useState(null); - - const getErrorMessage = (value: string): string | null => { - if (!value.trim()) return null; - - const numValue = parseInt(value, 10); - if (Number.isNaN(numValue)) { - return t(I18nKey.PAYMENT$ERROR_INVALID_NUMBER); - } - if (numValue < 0) { - return t(I18nKey.PAYMENT$ERROR_NEGATIVE_AMOUNT); - } - if (numValue < 10) { - return t(I18nKey.PAYMENT$ERROR_MINIMUM_AMOUNT); - } - if (numValue > 25000) { - return t(I18nKey.PAYMENT$ERROR_MAXIMUM_AMOUNT); - } - if (numValue !== parseFloat(value)) { - return t(I18nKey.PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER); - } - return null; - }; - - const formAction = (formData: FormData) => { - const amount = formData.get("amount")?.toString(); - - if (amount?.trim()) { - if (!amountIsValid(amount)) { - const error = getErrorMessage(amount); - setErrorMessage(error || "Invalid amount"); - return; - } - - const intValue = parseInt(amount, 10); - - addBalance({ amount: intValue }, { onSuccess: onClose }); - - setErrorMessage(null); - } - }; - - const handleAmountInputChange = (value: string) => { - setInputValue(value); - setErrorMessage(null); - }; - - return ( - -
-

{t(I18nKey.ORG$ADD_CREDITS)}

-
- handleAmountInputChange(value)} - className="w-full" - /> - {errorMessage && ( -

- {errorMessage} -

- )} -
- - - -
- ); -} - export const clientLoader = createPermissionGuard("view_billing"); function ManageOrg() { From 38648bddb3afbbef1fc93c5631724118cbc69191 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:13:02 +0700 Subject: [PATCH 83/92] fix(frontend): use correct git path based on sandbox grouping strategy (#13488) --- frontend/__tests__/utils/get-git-path.test.ts | 105 +++++++++++++++--- .../query/use-unified-get-git-changes.ts | 13 ++- .../src/hooks/query/use-unified-git-diff.ts | 21 +++- frontend/src/utils/get-git-path.ts | 20 +++- 4 files changed, 132 insertions(+), 27 deletions(-) diff --git a/frontend/__tests__/utils/get-git-path.test.ts b/frontend/__tests__/utils/get-git-path.test.ts index 2adfc232d4..a1f3512862 100644 --- a/frontend/__tests__/utils/get-git-path.test.ts +++ b/frontend/__tests__/utils/get-git-path.test.ts @@ -4,27 +4,96 @@ import { getGitPath } from "#/utils/get-git-path"; describe("getGitPath", () => { const conversationId = "abc123"; - it("should return /workspace/project/{conversationId} when no repository is selected", () => { - expect(getGitPath(conversationId, null)).toBe(`/workspace/project/${conversationId}`); - expect(getGitPath(conversationId, undefined)).toBe(`/workspace/project/${conversationId}`); + describe("without sandbox grouping (NO_GROUPING)", () => { + it("should return /workspace/project when no repository is selected", () => { + expect(getGitPath(conversationId, null, false)).toBe("/workspace/project"); + expect(getGitPath(conversationId, undefined, false)).toBe( + "/workspace/project", + ); + }); + + it("should handle standard owner/repo format (GitHub)", () => { + expect(getGitPath(conversationId, "OpenHands/OpenHands", false)).toBe( + "/workspace/project/OpenHands", + ); + expect(getGitPath(conversationId, "facebook/react", false)).toBe( + "/workspace/project/react", + ); + }); + + it("should handle nested group paths (GitLab)", () => { + expect( + getGitPath(conversationId, "modernhealth/frontend-guild/pan", false), + ).toBe("/workspace/project/pan"); + expect(getGitPath(conversationId, "group/subgroup/repo", false)).toBe( + "/workspace/project/repo", + ); + expect(getGitPath(conversationId, "a/b/c/d/repo", false)).toBe( + "/workspace/project/repo", + ); + }); + + it("should handle single segment paths", () => { + expect(getGitPath(conversationId, "repo", false)).toBe( + "/workspace/project/repo", + ); + }); + + it("should handle empty string", () => { + expect(getGitPath(conversationId, "", false)).toBe("/workspace/project"); + }); }); - it("should handle standard owner/repo format (GitHub)", () => { - expect(getGitPath(conversationId, "OpenHands/OpenHands")).toBe(`/workspace/project/${conversationId}/OpenHands`); - expect(getGitPath(conversationId, "facebook/react")).toBe(`/workspace/project/${conversationId}/react`); + describe("with sandbox grouping enabled", () => { + it("should return /workspace/project/{conversationId} when no repository is selected", () => { + expect(getGitPath(conversationId, null, true)).toBe( + `/workspace/project/${conversationId}`, + ); + expect(getGitPath(conversationId, undefined, true)).toBe( + `/workspace/project/${conversationId}`, + ); + }); + + it("should handle standard owner/repo format (GitHub)", () => { + expect(getGitPath(conversationId, "OpenHands/OpenHands", true)).toBe( + `/workspace/project/${conversationId}/OpenHands`, + ); + expect(getGitPath(conversationId, "facebook/react", true)).toBe( + `/workspace/project/${conversationId}/react`, + ); + }); + + it("should handle nested group paths (GitLab)", () => { + expect( + getGitPath(conversationId, "modernhealth/frontend-guild/pan", true), + ).toBe(`/workspace/project/${conversationId}/pan`); + expect(getGitPath(conversationId, "group/subgroup/repo", true)).toBe( + `/workspace/project/${conversationId}/repo`, + ); + expect(getGitPath(conversationId, "a/b/c/d/repo", true)).toBe( + `/workspace/project/${conversationId}/repo`, + ); + }); + + it("should handle single segment paths", () => { + expect(getGitPath(conversationId, "repo", true)).toBe( + `/workspace/project/${conversationId}/repo`, + ); + }); + + it("should handle empty string", () => { + expect(getGitPath(conversationId, "", true)).toBe( + `/workspace/project/${conversationId}`, + ); + }); }); - it("should handle nested group paths (GitLab)", () => { - expect(getGitPath(conversationId, "modernhealth/frontend-guild/pan")).toBe(`/workspace/project/${conversationId}/pan`); - expect(getGitPath(conversationId, "group/subgroup/repo")).toBe(`/workspace/project/${conversationId}/repo`); - expect(getGitPath(conversationId, "a/b/c/d/repo")).toBe(`/workspace/project/${conversationId}/repo`); - }); - - it("should handle single segment paths", () => { - expect(getGitPath(conversationId, "repo")).toBe(`/workspace/project/${conversationId}/repo`); - }); - - it("should handle empty string", () => { - expect(getGitPath(conversationId, "")).toBe(`/workspace/project/${conversationId}`); + describe("default behavior (useSandboxGrouping defaults to false)", () => { + it("should default to no sandbox grouping", () => { + expect(getGitPath(conversationId, null)).toBe("/workspace/project"); + expect(getGitPath(conversationId, "owner/repo")).toBe( + "/workspace/project/repo", + ); + }); }); }); diff --git a/frontend/src/hooks/query/use-unified-get-git-changes.ts b/frontend/src/hooks/query/use-unified-get-git-changes.ts index a1de3852f9..616665a07f 100644 --- a/frontend/src/hooks/query/use-unified-get-git-changes.ts +++ b/frontend/src/hooks/query/use-unified-get-git-changes.ts @@ -5,6 +5,7 @@ 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 { useSettings } from "#/hooks/query/use-settings"; import { getGitPath } from "#/utils/get-git-path"; import type { GitChange } from "#/api/open-hands.types"; @@ -16,6 +17,7 @@ import type { GitChange } from "#/api/open-hands.types"; export const useUnifiedGetGitChanges = () => { const { conversationId } = useConversationId(); const { data: conversation } = useActiveConversation(); + const { data: settings } = useSettings(); const [orderedChanges, setOrderedChanges] = React.useState([]); const previousDataRef = React.useRef(null); const runtimeIsReady = useRuntimeIsReady(); @@ -25,10 +27,15 @@ export const useUnifiedGetGitChanges = () => { const sessionApiKey = conversation?.session_api_key; const selectedRepository = conversation?.selected_repository; - // Calculate git path based on selected repository + // Sandbox grouping is enabled when strategy is not NO_GROUPING + const useSandboxGrouping = + settings?.sandbox_grouping_strategy !== "NO_GROUPING" && + settings?.sandbox_grouping_strategy !== undefined; + + // Calculate git path based on selected repository and sandbox grouping strategy const gitPath = React.useMemo( - () => getGitPath(conversationId, selectedRepository), - [selectedRepository], + () => getGitPath(conversationId, selectedRepository, useSandboxGrouping), + [conversationId, selectedRepository, useSandboxGrouping], ); const result = useQuery({ diff --git a/frontend/src/hooks/query/use-unified-git-diff.ts b/frontend/src/hooks/query/use-unified-git-diff.ts index 26bca16fce..8705a70c76 100644 --- a/frontend/src/hooks/query/use-unified-git-diff.ts +++ b/frontend/src/hooks/query/use-unified-git-diff.ts @@ -4,6 +4,7 @@ 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 { useSettings } from "#/hooks/query/use-settings"; import { getGitPath } from "#/utils/get-git-path"; import type { GitChangeStatus } from "#/api/open-hands.types"; @@ -21,20 +22,36 @@ type UseUnifiedGitDiffConfig = { export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => { const { conversationId } = useConversationId(); const { data: conversation } = useActiveConversation(); + const { data: settings } = useSettings(); const isV1Conversation = conversation?.conversation_version === "V1"; const conversationUrl = conversation?.url; const sessionApiKey = conversation?.session_api_key; const selectedRepository = conversation?.selected_repository; + // Sandbox grouping is enabled when strategy is not NO_GROUPING + const useSandboxGrouping = + settings?.sandbox_grouping_strategy !== "NO_GROUPING" && + settings?.sandbox_grouping_strategy !== undefined; + // 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(conversationId, selectedRepository); + const gitPath = getGitPath( + conversationId, + selectedRepository, + useSandboxGrouping, + ); return `${gitPath}/${config.filePath}`; - }, [isV1Conversation, selectedRepository, config.filePath]); + }, [ + isV1Conversation, + conversationId, + selectedRepository, + useSandboxGrouping, + config.filePath, + ]); return useQuery({ queryKey: [ diff --git a/frontend/src/utils/get-git-path.ts b/frontend/src/utils/get-git-path.ts index 39292b819f..e55b0bb989 100644 --- a/frontend/src/utils/get-git-path.ts +++ b/frontend/src/utils/get-git-path.ts @@ -1,17 +1,29 @@ /** * Get the git repository path for a conversation - * If a repository is selected, returns /workspace/project/{repo-name} - * Otherwise, returns /workspace/project * + * When sandbox grouping is enabled (strategy != NO_GROUPING), each conversation + * gets its own subdirectory: /workspace/project/{conversationId}[/{repoName}] + * + * When sandbox grouping is disabled (NO_GROUPING), the path is simply: + * /workspace/project[/{repoName}] + * + * @param conversationId The conversation ID * @param selectedRepository The selected repository (e.g., "OpenHands/OpenHands", "owner/repo", or "group/subgroup/repo") + * @param useSandboxGrouping Whether sandbox grouping is enabled (strategy != NO_GROUPING) * @returns The git path to use */ export function getGitPath( conversationId: string, selectedRepository: string | null | undefined, + useSandboxGrouping: boolean = false, ): string { + // Base path depends on sandbox grouping strategy + const basePath = useSandboxGrouping + ? `/workspace/project/${conversationId}` + : "/workspace/project"; + if (!selectedRepository) { - return `/workspace/project/${conversationId}`; + return basePath; } // Extract the repository name from the path @@ -19,5 +31,5 @@ export function getGitPath( const parts = selectedRepository.split("/"); const repoName = parts[parts.length - 1]; - return `/workspace/project/${conversationId}/${repoName}`; + return `${basePath}/${repoName}`; } From 49a98885aba218379f8876d30006828aa576debb Mon Sep 17 00:00:00 2001 From: aivong-openhands Date: Thu, 19 Mar 2026 14:33:23 -0500 Subject: [PATCH 84/92] chore: Update OpenSSL in Debian images for security patches (#13401) Co-authored-by: openhands --- openhands/runtime/utils/runtime_templates/Dockerfile.j2 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openhands/runtime/utils/runtime_templates/Dockerfile.j2 b/openhands/runtime/utils/runtime_templates/Dockerfile.j2 index a02229995f..78ee532fe7 100644 --- a/openhands/runtime/utils/runtime_templates/Dockerfile.j2 +++ b/openhands/runtime/utils/runtime_templates/Dockerfile.j2 @@ -46,6 +46,9 @@ RUN apt-get update && \ (apt-get install -y --no-install-recommends libgl1 || apt-get install -y --no-install-recommends libgl1-mesa-glx) && \ # Install Docker dependencies apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release && \ + # Security upgrade: patch OpenSSL CVEs (CVE-2025-15467, CVE-2025-69419, CVE-2025-69421, et al.) + (apt-get install -y --no-install-recommends --only-upgrade \ + openssl openssl-provider-legacy libssl3t64 || true) && \ # Security upgrade: patch ImageMagick CVEs (CVE-2026-25897, CVE-2026-25968, CVE-2026-26284, et al.) (apt-get install -y --no-install-recommends --only-upgrade \ imagemagick imagemagick-7-common imagemagick-7.q16 \ From 0137201903c05d748da99a36e0f68b512d24ed6b Mon Sep 17 00:00:00 2001 From: aivong-openhands Date: Thu, 19 Mar 2026 14:36:22 -0500 Subject: [PATCH 85/92] fix: remove vulnerable VSCode extensions in build_from_scratch path (#13399) Co-authored-by: openhands Co-authored-by: Ray Myers --- openhands/runtime/utils/runtime_templates/Dockerfile.j2 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openhands/runtime/utils/runtime_templates/Dockerfile.j2 b/openhands/runtime/utils/runtime_templates/Dockerfile.j2 index 78ee532fe7..9bf06c54b2 100644 --- a/openhands/runtime/utils/runtime_templates/Dockerfile.j2 +++ b/openhands/runtime/utils/runtime_templates/Dockerfile.j2 @@ -360,6 +360,14 @@ RUN chmod a+rwx /openhands/code/openhands/__init__.py && \ chown -R openhands:openhands /openhands/code +# ================================================================ +# Install VSCode extensions for build_from_scratch +# (must be after setup_vscode_server and source file copy) +# ================================================================ +{% if build_from_scratch %} +{{ install_vscode_extensions() }} +{% endif %} + # ================================================================ # END: Build from versioned image # ================================================================ From f706a217d0e658c088d22a7192067e506ddd79a5 Mon Sep 17 00:00:00 2001 From: Joe Laverty Date: Thu, 19 Mar 2026 16:24:07 -0400 Subject: [PATCH 86/92] fix: Use commit SHA instead of mutable branch tag for enterprise base (#13498) --- .github/workflows/ghcr-build.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ghcr-build.yml b/.github/workflows/ghcr-build.yml index 86ba722cec..bd0718e651 100644 --- a/.github/workflows/ghcr-build.yml +++ b/.github/workflows/ghcr-build.yml @@ -219,11 +219,9 @@ jobs: - name: Determine app image tag shell: bash run: | - # Duplicated with build.sh - sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g') - OPENHANDS_BUILD_VERSION=$sanitized_ref_name - sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging - echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV + # Use the commit SHA to pin the exact app image built by ghcr_build_app, + # rather than a mutable branch tag like "main" which can serve stale cached layers. + echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV - name: Build and push Docker image uses: useblacksmith/build-push-action@v1 with: From a8f6a353416e391271f17c3be84f044fcb840c6a Mon Sep 17 00:00:00 2001 From: aivong-openhands Date: Thu, 19 Mar 2026 16:21:24 -0500 Subject: [PATCH 87/92] fix: patch GLib CVE-2025-14087 in runtime Docker images (#13403) Co-authored-by: openhands --- openhands/runtime/utils/runtime_templates/Dockerfile.j2 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openhands/runtime/utils/runtime_templates/Dockerfile.j2 b/openhands/runtime/utils/runtime_templates/Dockerfile.j2 index 9bf06c54b2..69eb841a9f 100644 --- a/openhands/runtime/utils/runtime_templates/Dockerfile.j2 +++ b/openhands/runtime/utils/runtime_templates/Dockerfile.j2 @@ -46,6 +46,9 @@ RUN apt-get update && \ (apt-get install -y --no-install-recommends libgl1 || apt-get install -y --no-install-recommends libgl1-mesa-glx) && \ # Install Docker dependencies apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release && \ + # Security upgrade: patch GLib CVE-2025-14087 (buffer underflow in GVariant parser) + (apt-get install -y --no-install-recommends --only-upgrade \ + libglib2.0-0t64 libglib2.0-bin libglib2.0-dev libglib2.0-dev-bin || true) && \ # Security upgrade: patch OpenSSL CVEs (CVE-2025-15467, CVE-2025-69419, CVE-2025-69421, et al.) (apt-get install -y --no-install-recommends --only-upgrade \ openssl openssl-provider-legacy libssl3t64 || true) && \ From e4515b21eba030cec031cbd48f3e5051327b57b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:28:15 -0400 Subject: [PATCH 88/92] chore(deps): bump socket.io-parser from 4.2.5 to 4.2.6 in /frontend in the security-all group across 1 directory (#13474) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cd20c8aa82..811131f3cb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15325,10 +15325,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", - "license": "MIT", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" From f75141af3e94528c18536b6d7697fe0ed652f17c Mon Sep 17 00:00:00 2001 From: chuckbutkus Date: Thu, 19 Mar 2026 19:34:12 -0400 Subject: [PATCH 89/92] fix: prevent secrets deletion across organizations when storing secrets (#13500) Co-authored-by: openhands --- enterprise/storage/saas_secrets_store.py | 13 +-- .../tests/unit/test_saas_secrets_store.py | 79 +++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/enterprise/storage/saas_secrets_store.py b/enterprise/storage/saas_secrets_store.py index aede6df419..f4fb310556 100644 --- a/enterprise/storage/saas_secrets_store.py +++ b/enterprise/storage/saas_secrets_store.py @@ -59,12 +59,15 @@ class SaasSecretsStore(SecretsStore): async with a_session_maker() as session: # Incoming secrets are always the most updated ones - # Delete all existing records and override with incoming ones - await session.execute( - delete(StoredCustomSecrets).filter( - StoredCustomSecrets.keycloak_user_id == self.user_id - ) + # Delete existing records for this user AND organization only + delete_query = delete(StoredCustomSecrets).filter( + StoredCustomSecrets.keycloak_user_id == self.user_id ) + if org_id is not None: + delete_query = delete_query.filter(StoredCustomSecrets.org_id == org_id) + else: + delete_query = delete_query.filter(StoredCustomSecrets.org_id.is_(None)) + await session.execute(delete_query) # Prepare the new secrets data kwargs = item.model_dump(context={'expose_secrets': True}) diff --git a/enterprise/tests/unit/test_saas_secrets_store.py b/enterprise/tests/unit/test_saas_secrets_store.py index f9a560d11c..740507a973 100644 --- a/enterprise/tests/unit/test_saas_secrets_store.py +++ b/enterprise/tests/unit/test_saas_secrets_store.py @@ -246,3 +246,82 @@ class TestSaasSecretsStore: assert isinstance(store, SaasSecretsStore) assert store.user_id == 'test-user-id' assert store.config == mock_config + + @pytest.mark.asyncio + @patch( + 'storage.saas_secrets_store.UserStore.get_user_by_id', + new_callable=AsyncMock, + ) + async def test_secrets_isolation_between_organizations( + self, mock_get_user, secrets_store, mock_user + ): + """Test that secrets from one organization are not deleted when storing + secrets in another organization. This reproduces a bug where switching + organizations and creating a secret would delete all secrets from the + user's personal workspace.""" + org1_id = UUID('a1111111-1111-1111-1111-111111111111') + org2_id = UUID('b2222222-2222-2222-2222-222222222222') + + # Store secrets in org1 (personal workspace) + mock_user.current_org_id = org1_id + mock_get_user.return_value = mock_user + org1_secrets = Secrets( + custom_secrets=MappingProxyType( + { + 'personal_secret': CustomSecret.from_value( + { + 'secret': 'personal_secret_value', + 'description': 'My personal secret', + } + ), + } + ) + ) + await secrets_store.store(org1_secrets) + + # Verify org1 secrets are stored + loaded_org1 = await secrets_store.load() + assert loaded_org1 is not None + assert 'personal_secret' in loaded_org1.custom_secrets + assert ( + loaded_org1.custom_secrets['personal_secret'].secret.get_secret_value() + == 'personal_secret_value' + ) + + # Switch to org2 and store secrets there + mock_user.current_org_id = org2_id + mock_get_user.return_value = mock_user + org2_secrets = Secrets( + custom_secrets=MappingProxyType( + { + 'org2_secret': CustomSecret.from_value( + {'secret': 'org2_secret_value', 'description': 'Org2 secret'} + ), + } + ) + ) + await secrets_store.store(org2_secrets) + + # Verify org2 secrets are stored + loaded_org2 = await secrets_store.load() + assert loaded_org2 is not None + assert 'org2_secret' in loaded_org2.custom_secrets + assert ( + loaded_org2.custom_secrets['org2_secret'].secret.get_secret_value() + == 'org2_secret_value' + ) + + # Switch back to org1 and verify secrets are still there + mock_user.current_org_id = org1_id + mock_get_user.return_value = mock_user + loaded_org1_again = await secrets_store.load() + assert loaded_org1_again is not None + assert 'personal_secret' in loaded_org1_again.custom_secrets + assert ( + loaded_org1_again.custom_secrets[ + 'personal_secret' + ].secret.get_secret_value() + == 'personal_secret_value' + ) + # Verify org2 secrets are NOT visible in org1 + assert 'org2_secret' not in loaded_org1_again.custom_secrets From 63956c329270e376c37755c0cc326558633db1cc Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Thu, 19 Mar 2026 20:27:10 -0400 Subject: [PATCH 90/92] Fix FastAPI Query parameter validation: lte -> le (#13502) Co-authored-by: openhands --- enterprise/server/routes/orgs.py | 4 +- .../sharing/shared_conversation_router.py | 12 +- .../server/sharing/shared_event_router.py | 12 +- .../app_conversation_router.py | 14 +- openhands/app_server/event/event_router.py | 12 +- .../app_server/sandbox/sandbox_router.py | 10 +- .../app_server/sandbox/sandbox_spec_router.py | 12 +- tests/unit/app_server/test_event_router.py | 200 ++++++++++++++++++ 8 files changed, 243 insertions(+), 33 deletions(-) create mode 100644 tests/unit/app_server/test_event_router.py diff --git a/enterprise/server/routes/orgs.py b/enterprise/server/routes/orgs.py index a39f959864..3a49f23d70 100644 --- a/enterprise/server/routes/orgs.py +++ b/enterprise/server/routes/orgs.py @@ -68,7 +68,7 @@ async def list_user_orgs( ] = None, limit: Annotated[ int, - Query(title='The max number of results in the page', gt=0, lte=100), + Query(title='The max number of results in the page', gt=0, le=100), ] = 100, user_id: str = Depends(get_user_id), ) -> OrgPage: @@ -734,7 +734,7 @@ async def get_org_members( Query( title='The max number of results in the page', gt=0, - lte=100, + le=100, ), ] = 10, email: Annotated[ diff --git a/enterprise/server/sharing/shared_conversation_router.py b/enterprise/server/sharing/shared_conversation_router.py index 26fe047e6d..529dca3914 100644 --- a/enterprise/server/sharing/shared_conversation_router.py +++ b/enterprise/server/sharing/shared_conversation_router.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from server.sharing.shared_conversation_info_service import ( SharedConversationInfoService, ) @@ -60,7 +60,7 @@ async def search_shared_conversations( Query( title='The max number of results in the page', gt=0, - lte=100, + le=100, ), ] = 100, include_sub_conversations: Annotated[ @@ -72,8 +72,6 @@ async def search_shared_conversations( shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency, ) -> SharedConversationPage: """Search / List shared conversations.""" - assert limit > 0 - assert limit <= 100 return await shared_conversation_service.search_shared_conversation_info( title__contains=title__contains, created_at__gte=created_at__gte, @@ -127,7 +125,11 @@ async def batch_get_shared_conversations( shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency, ) -> list[SharedConversation | None]: """Get a batch of shared conversations given their ids. Return None for any missing or non-shared.""" - assert len(ids) <= 100 + if len(ids) > 100: + raise HTTPException( + status_code=400, + detail=f'Cannot request more than 100 conversations at once, got {len(ids)}', + ) uuids = [UUID(id_) for id_ in ids] shared_conversation_info = ( await shared_conversation_service.batch_get_shared_conversation_info(uuids) diff --git a/enterprise/server/sharing/shared_event_router.py b/enterprise/server/sharing/shared_event_router.py index 1f42d1d32e..2d8b500126 100644 --- a/enterprise/server/sharing/shared_event_router.py +++ b/enterprise/server/sharing/shared_event_router.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from server.sharing.shared_event_service import ( SharedEventService, SharedEventServiceInjector, @@ -77,13 +77,11 @@ async def search_shared_events( ] = None, limit: Annotated[ int, - Query(title='The max number of results in the page', gt=0, lte=100), + Query(title='The max number of results in the page', gt=0, le=100), ] = 100, shared_event_service: SharedEventService = shared_event_service_dependency, ) -> EventPage: """Search / List events for a shared conversation.""" - assert limit > 0 - assert limit <= 100 return await shared_event_service.search_shared_events( conversation_id=UUID(conversation_id), kind__eq=kind__eq, @@ -134,7 +132,11 @@ async def batch_get_shared_events( shared_event_service: SharedEventService = shared_event_service_dependency, ) -> list[Event | None]: """Get a batch of events for a shared conversation given their ids, returning null for any missing event.""" - assert len(id) <= 100 + if len(id) > 100: + raise HTTPException( + status_code=400, + detail=f'Cannot request more than 100 events at once, got {len(id)}', + ) event_ids = [UUID(id_) for id_ in id] events = await shared_event_service.batch_get_shared_events( UUID(conversation_id), event_ids diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py index 582de93761..02fb97986d 100644 --- a/openhands/app_server/app_conversation/app_conversation_router.py +++ b/openhands/app_server/app_conversation/app_conversation_router.py @@ -234,7 +234,7 @@ async def search_app_conversations( Query( title='The max number of results in the page', gt=0, - lte=100, + le=100, ), ] = 100, include_sub_conversations: Annotated[ @@ -248,8 +248,6 @@ async def search_app_conversations( ), ) -> AppConversationPage: """Search / List sandboxed conversations.""" - assert limit > 0 - assert limit <= 100 return await app_conversation_service.search_app_conversations( title__contains=title__contains, created_at__gte=created_at__gte, @@ -422,7 +420,7 @@ async def search_app_conversation_start_tasks( Query( title='The max number of results in the page', gt=0, - lte=100, + le=100, ), ] = 100, app_conversation_start_task_service: AppConversationStartTaskService = ( @@ -430,8 +428,6 @@ async def search_app_conversation_start_tasks( ), ) -> AppConversationStartTaskPage: """Search / List conversation start tasks.""" - assert limit > 0 - assert limit <= 100 return ( await app_conversation_start_task_service.search_app_conversation_start_tasks( conversation_id__eq=conversation_id__eq, @@ -472,7 +468,11 @@ async def batch_get_app_conversation_start_tasks( ), ) -> list[AppConversationStartTask | None]: """Get a batch of start app conversation tasks given their ids. Return None for any missing.""" - assert len(ids) < 100 + if len(ids) > 100: + raise HTTPException( + status_code=400, + detail=f'Cannot request more than 100 start tasks at once, got {len(ids)}', + ) start_tasks = await app_conversation_start_task_service.batch_get_app_conversation_start_tasks( ids ) diff --git a/openhands/app_server/event/event_router.py b/openhands/app_server/event/event_router.py index 522a53c273..ae525d3e04 100644 --- a/openhands/app_server/event/event_router.py +++ b/openhands/app_server/event/event_router.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Query +from fastapi import APIRouter, HTTPException, Query from openhands.agent_server.models import EventPage, EventSortOrder from openhands.app_server.config import depends_event_service @@ -51,13 +51,11 @@ async def search_events( ] = None, limit: Annotated[ int, - Query(title='The max number of results in the page', gt=0, lte=100), + Query(title='The max number of results in the page', gt=0, le=100), ] = 100, event_service: EventService = event_service_dependency, ) -> EventPage: """Search / List events.""" - assert limit > 0 - assert limit <= 100 return await event_service.search_events( conversation_id=UUID(conversation_id), kind__eq=kind__eq, @@ -102,7 +100,11 @@ async def batch_get_events( event_service: EventService = event_service_dependency, ) -> list[Event | None]: """Get a batch of events given their ids, returning null for any missing event.""" + if len(id) > 100: + raise HTTPException( + status_code=400, + detail=f'Cannot request more than 100 events at once, got {len(id)}', + ) event_ids = [UUID(id_) for id_ in id] - assert len(id) <= 100 events = await event_service.batch_get_events(UUID(conversation_id), event_ids) return events diff --git a/openhands/app_server/sandbox/sandbox_router.py b/openhands/app_server/sandbox/sandbox_router.py index 7b2575c3e7..54dedecfbb 100644 --- a/openhands/app_server/sandbox/sandbox_router.py +++ b/openhands/app_server/sandbox/sandbox_router.py @@ -44,13 +44,11 @@ async def search_sandboxes( ] = None, limit: Annotated[ int, - Query(title='The max number of results in the page', gt=0, lte=100), + Query(title='The max number of results in the page', gt=0, le=100), ] = 100, sandbox_service: SandboxService = sandbox_service_dependency, ) -> SandboxPage: """Search / list sandboxes owned by the current user.""" - assert limit > 0 - assert limit <= 100 return await sandbox_service.search_sandboxes(page_id=page_id, limit=limit) @@ -60,7 +58,11 @@ async def batch_get_sandboxes( sandbox_service: SandboxService = sandbox_service_dependency, ) -> list[SandboxInfo | None]: """Get a batch of sandboxes given their ids, returning null for any missing.""" - assert len(id) < 100 + if len(id) > 100: + raise HTTPException( + status_code=400, + detail=f'Cannot request more than 100 sandboxes at once, got {len(id)}', + ) sandboxes = await sandbox_service.batch_get_sandboxes(id) return sandboxes diff --git a/openhands/app_server/sandbox/sandbox_spec_router.py b/openhands/app_server/sandbox/sandbox_spec_router.py index 6da3353f39..1a1f98bb96 100644 --- a/openhands/app_server/sandbox/sandbox_spec_router.py +++ b/openhands/app_server/sandbox/sandbox_spec_router.py @@ -2,7 +2,7 @@ from typing import Annotated -from fastapi import APIRouter, Query +from fastapi import APIRouter, HTTPException, Query from openhands.app_server.config import depends_sandbox_spec_service from openhands.app_server.sandbox.sandbox_spec_models import ( @@ -35,13 +35,11 @@ async def search_sandbox_specs( ] = None, limit: Annotated[ int, - Query(title='The max number of results in the page', gt=0, lte=100), + Query(title='The max number of results in the page', gt=0, le=100), ] = 100, sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency, ) -> SandboxSpecInfoPage: """Search / List sandbox specs.""" - assert limit > 0 - assert limit <= 100 return await sandbox_spec_service.search_sandbox_specs(page_id=page_id, limit=limit) @@ -51,6 +49,10 @@ async def batch_get_sandbox_specs( sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency, ) -> list[SandboxSpecInfo | None]: """Get a batch of sandbox specs given their ids, returning null for any missing.""" - assert len(id) <= 100 + if len(id) > 100: + raise HTTPException( + status_code=400, + detail=f'Cannot request more than 100 sandbox specs at once, got {len(id)}', + ) sandbox_specs = await sandbox_spec_service.batch_get_sandbox_specs(id) return sandbox_specs diff --git a/tests/unit/app_server/test_event_router.py b/tests/unit/app_server/test_event_router.py new file mode 100644 index 0000000000..a3b94dd0ff --- /dev/null +++ b/tests/unit/app_server/test_event_router.py @@ -0,0 +1,200 @@ +"""Unit tests for the event_router endpoints. + +This module tests the event router endpoints, +focusing on limit validation and error handling. +""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, HTTPException, status +from fastapi.testclient import TestClient + +from openhands.app_server.event.event_router import batch_get_events, router +from openhands.server.dependencies import check_session_api_key + + +def _make_mock_event_service(search_return=None, batch_get_return=None): + """Create a mock EventService for testing.""" + service = MagicMock() + service.search_events = AsyncMock(return_value=search_return) + service.batch_get_events = AsyncMock(return_value=batch_get_return or []) + return service + + +@pytest.fixture +def test_client(): + """Create a test client with the actual event router and mocked dependencies. + + We override check_session_api_key to bypass auth checks. + This allows us to test the actual Query parameter validation in the router. + """ + app = FastAPI() + app.include_router(router) + + # Override the auth dependency to always pass + app.dependency_overrides[check_session_api_key] = lambda: None + + client = TestClient(app, raise_server_exceptions=False) + yield client + + # Clean up + app.dependency_overrides.clear() + + +class TestSearchEventsValidation: + """Test suite for search_events endpoint limit validation via FastAPI.""" + + def test_returns_422_for_limit_exceeding_100(self, test_client): + """Test that limit > 100 returns 422 Unprocessable Entity. + + FastAPI's Query validation (le=100) should reject limit=200. + """ + conversation_id = str(uuid4()) + + response = test_client.get( + f'/conversation/{conversation_id}/events/search', + params={'limit': 200}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + # Verify the error message mentions the constraint + error_detail = response.json()['detail'] + assert any( + 'less than or equal to 100' in str(err).lower() or 'le' in str(err).lower() + for err in error_detail + ) + + def test_returns_422_for_limit_zero(self, test_client): + """Test that limit=0 returns 422 Unprocessable Entity. + + FastAPI's Query validation (gt=0) should reject limit=0. + """ + conversation_id = str(uuid4()) + + response = test_client.get( + f'/conversation/{conversation_id}/events/search', + params={'limit': 0}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_returns_422_for_negative_limit(self, test_client): + """Test that negative limit returns 422 Unprocessable Entity. + + FastAPI's Query validation (gt=0) should reject limit=-1. + """ + conversation_id = str(uuid4()) + + response = test_client.get( + f'/conversation/{conversation_id}/events/search', + params={'limit': -1}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_accepts_valid_limit_100(self, test_client): + """Test that limit=100 is accepted (boundary case). + + Verify that limit=100 passes FastAPI validation and doesn't return 422. + """ + conversation_id = str(uuid4()) + + response = test_client.get( + f'/conversation/{conversation_id}/events/search', + params={'limit': 100}, + ) + + # Should pass validation (not 422) - may fail on other errors like missing service + assert response.status_code != status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_accepts_valid_limit_1(self, test_client): + """Test that limit=1 is accepted (boundary case). + + Verify that limit=1 passes FastAPI validation and doesn't return 422. + """ + conversation_id = str(uuid4()) + + response = test_client.get( + f'/conversation/{conversation_id}/events/search', + params={'limit': 1}, + ) + + # Should pass validation (not 422) - may fail on other errors like missing service + assert response.status_code != status.HTTP_422_UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +class TestBatchGetEvents: + """Test suite for batch_get_events endpoint.""" + + async def test_returns_400_for_more_than_100_ids(self): + """Test that requesting more than 100 IDs returns 400 Bad Request. + + Arrange: Create list with 101 IDs + Act: Call batch_get_events + Assert: HTTPException is raised with 400 status + """ + # Arrange + conversation_id = str(uuid4()) + ids = [str(uuid4()) for _ in range(101)] + mock_service = _make_mock_event_service() + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await batch_get_events( + conversation_id=conversation_id, + id=ids, + event_service=mock_service, + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert 'Cannot request more than 100 events' in exc_info.value.detail + assert '101' in exc_info.value.detail + + async def test_accepts_exactly_100_ids(self): + """Test that exactly 100 IDs is accepted. + + Arrange: Create list with 100 IDs + Act: Call batch_get_events + Assert: No exception is raised and service is called + """ + # Arrange + conversation_id = str(uuid4()) + ids = [str(uuid4()) for _ in range(100)] + mock_return = [None] * 100 + mock_service = _make_mock_event_service(batch_get_return=mock_return) + + # Act + result = await batch_get_events( + conversation_id=conversation_id, + id=ids, + event_service=mock_service, + ) + + # Assert + assert result == mock_return + mock_service.batch_get_events.assert_called_once() + + async def test_accepts_empty_list(self): + """Test that empty list of IDs is accepted. + + Arrange: Create empty list of IDs + Act: Call batch_get_events + Assert: No exception is raised + """ + # Arrange + conversation_id = str(uuid4()) + mock_service = _make_mock_event_service(batch_get_return=[]) + + # Act + result = await batch_get_events( + conversation_id=conversation_id, + id=[], + event_service=mock_service, + ) + + # Assert + assert result == [] + mock_service.batch_get_events.assert_called_once() From a75b576f1cf087650227c8d4df805f9f45e6758e Mon Sep 17 00:00:00 2001 From: Abi Date: Fri, 20 Mar 2026 15:44:15 +0530 Subject: [PATCH 91/92] fix: treat llm_base_url="" as explicit clear in store_llm_settings (#13471) Co-authored-by: Claude Sonnet 4.6 --- openhands/server/routes/settings.py | 10 ++++--- .../routes/test_settings_store_functions.py | 29 +++++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index 62944d11ce..4affad1d61 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -123,10 +123,9 @@ async def store_llm_settings( settings.llm_api_key = existing_settings.llm_api_key if settings.llm_model is None: settings.llm_model = existing_settings.llm_model - # if llm_base_url is missing or empty, try to preserve existing or determine appropriate URL - if not settings.llm_base_url: - if settings.llm_base_url is None and existing_settings.llm_base_url: - # Not provided at all (e.g. MCP config save) - preserve existing + if settings.llm_base_url is None: + # Not provided at all (e.g. MCP config save) - preserve existing or auto-detect + if existing_settings.llm_base_url: settings.llm_base_url = existing_settings.llm_base_url elif is_openhands_model(settings.llm_model): # OpenHands models use the LiteLLM proxy @@ -145,6 +144,9 @@ async def store_llm_settings( logger.error( f'Failed to get api_base from litellm for model {settings.llm_model}: {e}' ) + elif settings.llm_base_url == '': + # Explicitly cleared by the user (basic view save or advanced view clear) + settings.llm_base_url = None # Keep search API key if missing or empty if not settings.search_api_key: settings.search_api_key = existing_settings.search_api_key diff --git a/tests/unit/server/routes/test_settings_store_functions.py b/tests/unit/server/routes/test_settings_store_functions.py index f51a5b506a..48bc79a280 100644 --- a/tests/unit/server/routes/test_settings_store_functions.py +++ b/tests/unit/server/routes/test_settings_store_functions.py @@ -211,9 +211,32 @@ async def test_store_llm_settings_partial_update(): assert result.llm_model == 'gpt-4' # For SecretStr objects, we need to compare the secret value assert result.llm_api_key.get_secret_value() == 'existing-api-key' - # llm_base_url was explicitly cleared (""), so auto-detection runs - # OpenAI models: litellm.get_api_base() returns https://api.openai.com - assert result.llm_base_url == 'https://api.openai.com' + # llm_base_url="" is an explicit clear — must not be repopulated via auto-detection + assert result.llm_base_url is None + + +@pytest.mark.asyncio +async def test_store_llm_settings_advanced_view_clear_removes_base_url(): + """Regression test for #13420: clearing Base URL in Advanced view must persist. + + Before the fix, llm_base_url="" was treated identically to llm_base_url=None, + causing the backend to re-run auto-detection and overwrite the user's intent. + """ + settings = Settings( + llm_model='gpt-4', + llm_base_url='', # User deleted the field in Advanced view + ) + + existing_settings = Settings( + llm_model='gpt-4', + llm_api_key=SecretStr('my-api-key'), + llm_base_url='https://my-custom-proxy.example.com', + ) + + result = await store_llm_settings(settings, existing_settings) + + # The old custom URL must not come back + assert result.llm_base_url is None @pytest.mark.asyncio From fb776ef6509bd9015403f42ebb4c2fe9aca30009 Mon Sep 17 00:00:00 2001 From: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:20:25 +0100 Subject: [PATCH 92/92] feat(frontend): Add copy button to code blocks (#13458) Co-authored-by: openhands --- .../buttons/copyable-content-wrapper.test.tsx | 60 +++++++++++++++++++ .../features/markdown/code.test.tsx | 37 ++++++++++++ .../src/components/features/markdown/code.tsx | 46 +++++++------- .../buttons/copyable-content-wrapper.tsx | 44 ++++++++++++++ 4 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 frontend/__tests__/components/buttons/copyable-content-wrapper.test.tsx create mode 100644 frontend/__tests__/components/features/markdown/code.test.tsx create mode 100644 frontend/src/components/shared/buttons/copyable-content-wrapper.tsx diff --git a/frontend/__tests__/components/buttons/copyable-content-wrapper.test.tsx b/frontend/__tests__/components/buttons/copyable-content-wrapper.test.tsx new file mode 100644 index 0000000000..7c7aaee48b --- /dev/null +++ b/frontend/__tests__/components/buttons/copyable-content-wrapper.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper"; + +describe("CopyableContentWrapper", () => { + it("should hide the copy button by default", () => { + render( + +

content

+
, + ); + + expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible(); + }); + + it("should show the copy button on hover", async () => { + const user = userEvent.setup(); + render( + +

content

+
, + ); + + await user.hover(screen.getByText("content")); + + expect(screen.getByTestId("copy-to-clipboard")).toBeVisible(); + }); + + it("should copy text to clipboard on click", async () => { + const user = userEvent.setup(); + render( + +

content

+
, + ); + + await user.click(screen.getByTestId("copy-to-clipboard")); + + await waitFor(() => + expect(navigator.clipboard.readText()).resolves.toBe("copy me"), + ); + }); + + it("should show copied state after clicking", async () => { + const user = userEvent.setup(); + render( + +

content

+
, + ); + + await user.click(screen.getByTestId("copy-to-clipboard")); + + expect(screen.getByTestId("copy-to-clipboard")).toHaveAttribute( + "aria-label", + "BUTTON$COPIED", + ); + }); +}); diff --git a/frontend/__tests__/components/features/markdown/code.test.tsx b/frontend/__tests__/components/features/markdown/code.test.tsx new file mode 100644 index 0000000000..c5ba1562af --- /dev/null +++ b/frontend/__tests__/components/features/markdown/code.test.tsx @@ -0,0 +1,37 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { code as Code } from "#/components/features/markdown/code"; + +describe("code (markdown)", () => { + it("should render inline code without a copy button", () => { + render(inline snippet); + + expect(screen.getByText("inline snippet")).toBeInTheDocument(); + expect(screen.queryByTestId("copy-to-clipboard")).not.toBeInTheDocument(); + }); + + it("should render a multiline code block with a copy button", () => { + render({"line1\nline2"}); + + expect(screen.getByText("line1 line2")).toBeInTheDocument(); + expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument(); + }); + + it("should render a syntax-highlighted block with a copy button", () => { + render({"console.log('hi')"}); + + expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument(); + }); + + it("should copy code block content to clipboard", async () => { + const user = userEvent.setup(); + render({"line1\nline2"}); + + await user.click(screen.getByTestId("copy-to-clipboard")); + + await waitFor(() => + expect(navigator.clipboard.readText()).resolves.toBe("line1\nline2"), + ); + }); +}); diff --git a/frontend/src/components/features/markdown/code.tsx b/frontend/src/components/features/markdown/code.tsx index 2a801f6848..ee04ce53b5 100644 --- a/frontend/src/components/features/markdown/code.tsx +++ b/frontend/src/components/features/markdown/code.tsx @@ -2,6 +2,7 @@ import React from "react"; import { ExtraProps } from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper"; // See https://github.com/remarkjs/react-markdown?tab=readme-ov-file#use-custom-components-syntax-highlight @@ -15,6 +16,7 @@ export function code({ React.HTMLAttributes & ExtraProps) { const match = /language-(\w+)/.exec(className || ""); // get the language + const codeString = String(children).replace(/\n$/, ""); if (!match) { const isMultiline = String(children).includes("\n"); @@ -37,29 +39,33 @@ export function code({ } return ( -
-        {String(children).replace(/\n$/, "")}
-      
+ +
+          {codeString}
+        
+
); } return ( - - {String(children).replace(/\n$/, "")} - + + + {codeString} + + ); } diff --git a/frontend/src/components/shared/buttons/copyable-content-wrapper.tsx b/frontend/src/components/shared/buttons/copyable-content-wrapper.tsx new file mode 100644 index 0000000000..fe9a4d837a --- /dev/null +++ b/frontend/src/components/shared/buttons/copyable-content-wrapper.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { CopyToClipboardButton } from "./copy-to-clipboard-button"; + +export function CopyableContentWrapper({ + text, + children, +}: { + text: string; + children: React.ReactNode; +}) { + const [isHovering, setIsHovering] = React.useState(false); + const [isCopied, setIsCopied] = React.useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setIsCopied(true); + }; + + React.useEffect(() => { + let timeout: NodeJS.Timeout; + if (isCopied) { + timeout = setTimeout(() => setIsCopied(false), 2000); + } + return () => clearTimeout(timeout); + }, [isCopied]); + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > +
+ +
+ {children} +
+ ); +}