diff --git a/enterprise/integrations/github/github_manager.py b/enterprise/integrations/github/github_manager.py index e1c1a31fb4..00ad5124ce 100644 --- a/enterprise/integrations/github/github_manager.py +++ b/enterprise/integrations/github/github_manager.py @@ -22,6 +22,7 @@ from integrations.utils import ( HOST_URL, OPENHANDS_RESOLVER_TEMPLATES_DIR, ) +from integrations.v1_utils import get_saas_user_auth from jinja2 import Environment, FileSystemLoader from pydantic import SecretStr from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY @@ -164,8 +165,13 @@ class GithubManager(Manager): ) if await self.is_job_requested(message): + payload = message.message.get('payload', {}) + user_id = payload['sender']['id'] + keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id( + user_id, ProviderType.GITHUB + ) github_view = await GithubFactory.create_github_view_from_payload( - message, self.token_manager + message, keycloak_user_id ) logger.info( f'[GitHub] Creating job for {github_view.user_info.username} in {github_view.full_repo_name}#{github_view.issue_number}' @@ -282,8 +288,15 @@ class GithubManager(Manager): f'[Github]: Error summarizing issue solvability: {str(e)}' ) + saas_user_auth = await get_saas_user_auth( + github_view.user_info.keycloak_user_id, self.token_manager + ) + await github_view.create_new_conversation( - self.jinja_env, secret_store.provider_tokens, convo_metadata + self.jinja_env, + secret_store.provider_tokens, + convo_metadata, + saas_user_auth, ) conversation_id = github_view.conversation_id @@ -292,14 +305,7 @@ class GithubManager(Manager): f'[GitHub] Created conversation {conversation_id} for user {user_info.username}' ) - from openhands.server.shared import ConversationStoreImpl, config - - conversation_store = await ConversationStoreImpl.get_instance( - config, github_view.user_info.keycloak_user_id - ) - metadata = await conversation_store.get_metadata(conversation_id) - - if metadata.conversation_version != 'v1': + if not github_view.v1: # Create a GithubCallbackProcessor processor = GithubCallbackProcessor( github_view=github_view, diff --git a/enterprise/integrations/github/github_view.py b/enterprise/integrations/github/github_view.py index 733cec6c2a..fdb579f167 100644 --- a/enterprise/integrations/github/github_view.py +++ b/enterprise/integrations/github/github_view.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from uuid import UUID, uuid4 from github import Github, GithubIntegration @@ -8,6 +9,7 @@ from integrations.github.github_types import ( WorkflowRunStatus, ) from integrations.models import Message +from integrations.resolver_context import ResolverUserContext from integrations.types import ResolverViewInterface, UserData from integrations.utils import ( ENABLE_PROACTIVE_CONVERSATION_STARTERS, @@ -17,7 +19,6 @@ from integrations.utils import ( has_exact_mention, ) from jinja2 import Environment -from pydantic.dataclasses import dataclass from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY from server.auth.token_manager import TokenManager from server.config import get_config @@ -34,18 +35,16 @@ from openhands.app_server.app_conversation.app_conversation_models import ( from openhands.app_server.config import get_app_conversation_service 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.user.user_models import UserInfo from openhands.core.logger import openhands_logger as logger from openhands.integrations.github.github_service import GithubServiceImpl from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType from openhands.integrations.service_types import Comment from openhands.sdk import TextContent -from openhands.sdk.conversation.secret_source import SecretSource from openhands.server.services.conversation_service import ( initialize_conversation, start_conversation, ) +from openhands.server.user_auth.user_auth import UserAuth from openhands.storage.data_models.conversation_metadata import ( ConversationMetadata, ConversationTrigger, @@ -55,52 +54,6 @@ from openhands.utils.async_utils import call_sync_from_async OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST) -class GithubUserContext(UserContext): - """User context for GitHub integration that provides user info without web request.""" - - def __init__(self, keycloak_user_id: str, git_provider_tokens: PROVIDER_TOKEN_TYPE): - self.keycloak_user_id = keycloak_user_id - self.git_provider_tokens = git_provider_tokens - self.settings_store = SaasSettingsStore( - user_id=self.keycloak_user_id, - session_maker=session_maker, - config=get_config(), - ) - - self.secrets_store = SaasSecretsStore( - self.keycloak_user_id, session_maker, get_config() - ) - - async def get_user_id(self) -> str | None: - return self.keycloak_user_id - - async def get_user_info(self) -> UserInfo: - user_settings = await self.settings_store.load() - return UserInfo( - id=self.keycloak_user_id, - **user_settings.model_dump(context={'expose_secrets': True}), - ) - - async def get_authenticated_git_url(self, repository: str) -> str: - # This would need to be implemented based on the git provider tokens - # For now, return a basic HTTPS URL - return f'https://github.com/{repository}.git' - - async def get_latest_token(self, provider_type: ProviderType) -> str | None: - # Return the appropriate token from git_provider_tokens - if provider_type == ProviderType.GITHUB and self.git_provider_tokens: - return self.git_provider_tokens.get(ProviderType.GITHUB) - return None - - async def get_secrets(self) -> dict[str, SecretSource]: - # Return empty dict for now - GitHub integration handles secrets separately - user_secrets = await self.secrets_store.load() - return dict(user_secrets.custom_secrets) if user_secrets else {} - - async def get_mcp_api_key(self) -> str | None: - raise NotImplementedError() - - async def get_user_proactive_conversation_setting(user_id: str | None) -> bool: """Get the user's proactive conversation setting. @@ -134,7 +87,7 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool: return settings.enable_proactive_conversation_starters -async def get_user_v1_enabled_setting(user_id: str | None) -> bool: +async def get_user_v1_enabled_setting(user_id: str) -> bool: """Get the user's V1 conversation API setting. Args: @@ -143,11 +96,6 @@ async def get_user_v1_enabled_setting(user_id: str | None) -> bool: Returns: True if V1 conversations are enabled for this user, False otherwise """ - - # If no user ID is provided, we can't check user settings - if not user_id: - return False - config = get_config() settings_store = SaasSettingsStore( user_id=user_id, session_maker=session_maker, config=config @@ -183,6 +131,7 @@ class GithubIssue(ResolverViewInterface): title: str description: str previous_comments: list[Comment] + v1: bool async def _load_resolver_context(self): github_service = GithubServiceImpl( @@ -229,6 +178,19 @@ class GithubIssue(ResolverViewInterface): async def initialize_new_conversation(self) -> ConversationMetadata: # FIXME: Handle if initialize_conversation returns None + + v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id) + logger.info( + f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}' + ) + if v1_enabled: + # Create dummy conversationm metadata + # Don't save to conversation store + # V1 conversations are stored in a separate table + return ConversationMetadata( + conversation_id=uuid4().hex, selected_repository=self.full_repo_name + ) + conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment] user_id=self.user_info.keycloak_user_id, conversation_id=None, @@ -245,14 +207,17 @@ class GithubIssue(ResolverViewInterface): jinja_env: Environment, git_provider_tokens: PROVIDER_TOKEN_TYPE, conversation_metadata: ConversationMetadata, + saas_user_auth: UserAuth, ): v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id) - + logger.info( + f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}' + ) if v1_enabled: try: # Use V1 app conversation service await self._create_v1_conversation( - jinja_env, git_provider_tokens, conversation_metadata + jinja_env, saas_user_auth, conversation_metadata ) return @@ -271,6 +236,7 @@ class GithubIssue(ResolverViewInterface): conversation_metadata: ConversationMetadata, ): """Create conversation using the legacy V0 system.""" + logger.info('[GitHub V1]: Creating V0 conversation') custom_secrets = await self._get_user_secrets() user_instructions, conversation_instructions = await self._get_instructions( @@ -292,10 +258,12 @@ class GithubIssue(ResolverViewInterface): async def _create_v1_conversation( self, jinja_env: Environment, - git_provider_tokens: PROVIDER_TOKEN_TYPE, + saas_user_auth: UserAuth, conversation_metadata: ConversationMetadata, ): """Create conversation using the new V1 app conversation system.""" + logger.info('[GitHub V1]: Creating V1 conversation') + user_instructions, conversation_instructions = await self._get_instructions( jinja_env ) @@ -326,10 +294,7 @@ class GithubIssue(ResolverViewInterface): ) # Set up the GitHub user context for the V1 system - github_user_context = GithubUserContext( - keycloak_user_id=self.user_info.keycloak_user_id, - git_provider_tokens=git_provider_tokens, - ) + github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth) setattr(injector_state, USER_CONTEXT_ATTR, github_user_context) async with get_app_conversation_service( @@ -344,6 +309,8 @@ class GithubIssue(ResolverViewInterface): f'Failed to start V1 conversation: {task.detail}' ) + self.v1 = True + def _create_github_v1_callback_processor(self): """Create a V1 callback processor for GitHub integration.""" from openhands.app_server.event_callback.github_v1_callback_processor import ( @@ -806,7 +773,7 @@ class GithubFactory: @staticmethod async def create_github_view_from_payload( - message: Message, token_manager: TokenManager + message: Message, keycloak_user_id: str ) -> ResolverViewInterface: """Create the appropriate class (GithubIssue or GithubPRComment) based on the payload. Also return metadata about the event (e.g., action type). @@ -816,17 +783,10 @@ class GithubFactory: user_id = payload['sender']['id'] username = payload['sender']['login'] - keyloak_user_id = await token_manager.get_user_id_from_idp_user_id( - user_id, ProviderType.GITHUB - ) - - if keyloak_user_id is None: - logger.warning(f'Got invalid keyloak user id for GitHub User {user_id} ') - selected_repo = GithubFactory.get_full_repo_name(repo_obj) is_public_repo = not repo_obj.get('private', True) user_info = UserData( - user_id=user_id, username=username, keycloak_user_id=keyloak_user_id + user_id=user_id, username=username, keycloak_user_id=keycloak_user_id ) installation_id = message.message['installation'] @@ -850,6 +810,7 @@ class GithubFactory: title='', description='', previous_comments=[], + v1=False, ) elif GithubFactory.is_issue_comment(message): @@ -875,6 +836,7 @@ class GithubFactory: title='', description='', previous_comments=[], + v1=False, ) elif GithubFactory.is_pr_comment(message): @@ -916,6 +878,7 @@ class GithubFactory: title='', description='', previous_comments=[], + v1=False, ) elif GithubFactory.is_inline_pr_comment(message): @@ -949,6 +912,7 @@ class GithubFactory: title='', description='', previous_comments=[], + v1=False, ) else: diff --git a/enterprise/integrations/resolver_context.py b/enterprise/integrations/resolver_context.py new file mode 100644 index 0000000000..8fd7c24550 --- /dev/null +++ b/enterprise/integrations/resolver_context.py @@ -0,0 +1,63 @@ +from openhands.app_server.user.user_context import UserContext +from openhands.app_server.user.user_models import UserInfo +from openhands.integrations.provider import PROVIDER_TOKEN_TYPE +from openhands.integrations.service_types import ProviderType +from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret +from openhands.server.user_auth.user_auth import UserAuth + + +class ResolverUserContext(UserContext): + """User context for resolver operations that inherits from UserContext.""" + + def __init__( + self, + saas_user_auth: UserAuth, + ): + self.saas_user_auth = saas_user_auth + + async def get_user_id(self) -> str | None: + return await self.saas_user_auth.get_user_id() + + async def get_user_info(self) -> UserInfo: + user_settings = await self.saas_user_auth.get_user_settings() + user_id = await self.saas_user_auth.get_user_id() + if user_settings: + return UserInfo( + id=user_id, + **user_settings.model_dump(context={'expose_secrets': True}), + ) + + return UserInfo(id=user_id) + + async def get_authenticated_git_url(self, repository: str) -> str: + # This would need to be implemented based on the git provider tokens + # For now, return a basic HTTPS URL + return f'https://github.com/{repository}.git' + + async def get_latest_token(self, provider_type: ProviderType) -> str | None: + # Return the appropriate token from git_provider_tokens + + provider_tokens = await self.saas_user_auth.get_provider_tokens() + if provider_tokens: + return provider_tokens.get(provider_type) + return None + + async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None: + return await self.saas_user_auth.get_provider_tokens() + + async def get_secrets(self) -> dict[str, SecretSource]: + """Get secrets for the user, including custom secrets.""" + secrets = await self.saas_user_auth.get_secrets() + if secrets: + # Convert custom secrets to StaticSecret objects for SDK compatibility + # secrets.custom_secrets is of type Mapping[str, CustomSecret] + converted_secrets = {} + for key, custom_secret in secrets.custom_secrets.items(): + # Extract the secret value from CustomSecret and convert to StaticSecret + secret_value = custom_secret.secret.get_secret_value() + converted_secrets[key] = StaticSecret(value=secret_value) + return converted_secrets + return {} + + async def get_mcp_api_key(self) -> str | None: + return await self.saas_user_auth.get_mcp_api_key() diff --git a/enterprise/integrations/types.py b/enterprise/integrations/types.py index dcbcc9b7d3..0b8d79228c 100644 --- a/enterprise/integrations/types.py +++ b/enterprise/integrations/types.py @@ -19,7 +19,7 @@ class PRStatus(Enum): class UserData(BaseModel): user_id: int username: str - keycloak_user_id: str | None + keycloak_user_id: str @dataclass diff --git a/enterprise/integrations/v1_utils.py b/enterprise/integrations/v1_utils.py new file mode 100644 index 0000000000..78953e4e93 --- /dev/null +++ b/enterprise/integrations/v1_utils.py @@ -0,0 +1,20 @@ +from pydantic import SecretStr +from server.auth.saas_user_auth import SaasUserAuth +from server.auth.token_manager import TokenManager + +from openhands.core.logger import openhands_logger as logger +from openhands.server.user_auth.user_auth import UserAuth + + +async def get_saas_user_auth( + keycloak_user_id: str, token_manager: TokenManager +) -> UserAuth: + offline_token = await token_manager.load_offline_token(keycloak_user_id) + if offline_token is None: + logger.info('no_offline_token_found') + + user_auth = SaasUserAuth( + user_id=keycloak_user_id, + refresh_token=SecretStr(offline_token), + ) + return user_auth diff --git a/enterprise/server/routes/integration/github.py b/enterprise/server/routes/integration/github.py index d7bf857a3f..204b8297f7 100644 --- a/enterprise/server/routes/integration/github.py +++ b/enterprise/server/routes/integration/github.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import hmac import os @@ -58,7 +59,8 @@ async def github_events( ) try: - payload = await request.body() + # Add timeout to prevent hanging on slow/stalled clients + payload = await asyncio.wait_for(request.body(), timeout=15.0) verify_github_signature(payload, x_hub_signature_256) payload_data = await request.json() @@ -78,6 +80,12 @@ async def github_events( status_code=200, content={'message': 'GitHub events endpoint reached successfully.'}, ) + except asyncio.TimeoutError: + logger.warning('GitHub webhook request timed out waiting for request body') + return JSONResponse( + status_code=408, + content={'error': 'Request timeout - client took too long to send data.'}, + ) except Exception as e: logger.exception(f'Error processing GitHub event: {e}') return JSONResponse(status_code=400, content={'error': 'Invalid payload.'}) diff --git a/enterprise/tests/unit/integrations/test_resolver_context.py b/enterprise/tests/unit/integrations/test_resolver_context.py new file mode 100644 index 0000000000..baef6869db --- /dev/null +++ b/enterprise/tests/unit/integrations/test_resolver_context.py @@ -0,0 +1,133 @@ +"""Test for ResolverUserContext get_secrets conversion logic. + +This test focuses on testing the actual ResolverUserContext implementation. +""" + +from types import MappingProxyType +from unittest.mock import AsyncMock + +import pytest +from pydantic import SecretStr + +from enterprise.integrations.resolver_context import ResolverUserContext + +# Import the real classes we want to test +from openhands.integrations.provider import CustomSecret + +# Import the SDK types we need for testing +from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret +from openhands.storage.data_models.secrets import Secrets + + +@pytest.fixture +def mock_saas_user_auth(): + """Mock SaasUserAuth for testing.""" + return AsyncMock() + + +@pytest.fixture +def resolver_context(mock_saas_user_auth): + """Create a ResolverUserContext instance for testing.""" + return ResolverUserContext(saas_user_auth=mock_saas_user_auth) + + +def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret: + """Helper to create CustomSecret instances.""" + return CustomSecret(secret=SecretStr(value), description=description) + + +def create_secrets(custom_secrets_dict: dict[str, CustomSecret]) -> Secrets: + """Helper to create Secrets instances.""" + return Secrets(custom_secrets=MappingProxyType(custom_secrets_dict)) + + +@pytest.mark.asyncio +async def test_get_secrets_converts_custom_to_static( + resolver_context, mock_saas_user_auth +): + """Test that get_secrets correctly converts CustomSecret objects to StaticSecret objects.""" + # Arrange + secrets = create_secrets( + { + 'TEST_SECRET_1': create_custom_secret('secret_value_1'), + 'TEST_SECRET_2': create_custom_secret('secret_value_2'), + } + ) + mock_saas_user_auth.get_secrets.return_value = secrets + + # Act + result = await resolver_context.get_secrets() + + # Assert + assert len(result) == 2 + assert all(isinstance(secret, StaticSecret) for secret in result.values()) + assert result['TEST_SECRET_1'].value.get_secret_value() == 'secret_value_1' + assert result['TEST_SECRET_2'].value.get_secret_value() == 'secret_value_2' + + +@pytest.mark.asyncio +async def test_get_secrets_with_special_characters( + resolver_context, mock_saas_user_auth +): + """Test that secret values with special characters are preserved during conversion.""" + # Arrange + special_value = 'very_secret_password_123!@#$%^&*()' + secrets = create_secrets({'SPECIAL_SECRET': create_custom_secret(special_value)}) + mock_saas_user_auth.get_secrets.return_value = secrets + + # Act + result = await resolver_context.get_secrets() + + # Assert + assert len(result) == 1 + assert isinstance(result['SPECIAL_SECRET'], StaticSecret) + assert result['SPECIAL_SECRET'].value.get_secret_value() == special_value + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'secrets_input,expected_result', + [ + (None, {}), # No secrets available + (create_secrets({}), {}), # Empty custom secrets + ], +) +async def test_get_secrets_empty_cases( + resolver_context, mock_saas_user_auth, secrets_input, expected_result +): + """Test that get_secrets handles empty cases correctly.""" + # Arrange + mock_saas_user_auth.get_secrets.return_value = secrets_input + + # Act + result = await resolver_context.get_secrets() + + # Assert + assert result == expected_result + + +def test_static_secret_is_valid_secret_source(): + """Test that StaticSecret is a valid SecretSource for SDK validation.""" + # Arrange & Act + static_secret = StaticSecret(value='test_secret_123') + + # Assert + assert isinstance(static_secret, StaticSecret) + assert isinstance(static_secret, SecretSource) + assert static_secret.value.get_secret_value() == 'test_secret_123' + + +def test_custom_to_static_conversion(): + """Test the complete conversion flow from CustomSecret to StaticSecret.""" + # Arrange + secret_value = 'conversion_test_secret' + custom_secret = create_custom_secret(secret_value, 'Conversion test') + + # Act - simulate the conversion logic from the actual method + extracted_value = custom_secret.secret.get_secret_value() + static_secret = StaticSecret(value=extracted_value) + + # Assert + assert isinstance(static_secret, StaticSecret) + assert isinstance(static_secret, SecretSource) + assert static_secret.value.get_secret_value() == secret_value diff --git a/enterprise/tests/unit/test_github_view.py b/enterprise/tests/unit/test_github_view.py index 20f033d157..1edc46bc2a 100644 --- a/enterprise/tests/unit/test_github_view.py +++ b/enterprise/tests/unit/test_github_view.py @@ -1,6 +1,7 @@ from unittest import TestCase, mock from unittest.mock import MagicMock, patch +import pytest from integrations.github.github_view import GithubFactory, GithubIssue, get_oh_labels from integrations.models import Message, SourceType from integrations.types import UserData @@ -114,8 +115,10 @@ class TestGithubV1ConversationRouting(TestCase): title='Test Issue', description='Test issue description', previous_comments=[], + v1=False, ) + @pytest.mark.asyncio @patch('integrations.github.github_view.get_user_v1_enabled_setting') @patch.object(GithubIssue, '_create_v0_conversation') @patch.object(GithubIssue, '_create_v1_conversation') @@ -144,6 +147,7 @@ class TestGithubV1ConversationRouting(TestCase): ) mock_create_v1.assert_not_called() + @pytest.mark.asyncio @patch('integrations.github.github_view.get_user_v1_enabled_setting') @patch.object(GithubIssue, '_create_v0_conversation') @patch.object(GithubIssue, '_create_v1_conversation') @@ -172,6 +176,7 @@ class TestGithubV1ConversationRouting(TestCase): ) mock_create_v0.assert_not_called() + @pytest.mark.asyncio @patch('integrations.github.github_view.get_user_v1_enabled_setting') @patch.object(GithubIssue, '_create_v0_conversation') @patch.object(GithubIssue, '_create_v1_conversation') diff --git a/frontend/__tests__/components/chat/expandable-message.test.tsx b/frontend/__tests__/components/chat/expandable-message.test.tsx index 0b25ef1f92..4ba839b8af 100644 --- a/frontend/__tests__/components/chat/expandable-message.test.tsx +++ b/frontend/__tests__/components/chat/expandable-message.test.tsx @@ -61,7 +61,7 @@ describe("ExpandableMessage", () => { expect(icon).toHaveClass("fill-success"); }); - it("should render with error icon for failed action messages", () => { + it("should render with no icon for failed action messages", () => { renderWithProviders( { "div.flex.gap-2.items-center.justify-start", ); expect(container).toHaveClass("border-neutral-300"); - const icon = screen.getByTestId("status-icon"); - expect(icon).toHaveClass("fill-danger"); + expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument(); }); it("should render with neutral border and no icon for action messages without success prop", () => { 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 b5746d6d25..eb7c39397c 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 @@ -3,7 +3,7 @@ import { describe, expect, it, vi } 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 "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; describe("AnalyticsConsentFormModal", () => { it("should call saveUserSettings with consent", async () => { diff --git a/frontend/__tests__/components/features/home/repo-connector.test.tsx b/frontend/__tests__/components/features/home/repo-connector.test.tsx index 0500d441a2..4418d57db3 100644 --- a/frontend/__tests__/components/features/home/repo-connector.test.tsx +++ b/frontend/__tests__/components/features/home/repo-connector.test.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; import { createRoutesStub, Outlet } from "react-router"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import ConversationService from "#/api/conversation-service/conversation-service.api"; import GitService from "#/api/git-service/git-service.api"; import OptionService from "#/api/option-service/option-service.api"; @@ -404,7 +404,7 @@ describe("RepoConnector", () => { ConversationService, "createConversation", ); - createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state + createConversationSpy.mockImplementation(() => new Promise(() => { })); // Never resolves to keep loading state const retrieveUserGitRepositoriesSpy = vi.spyOn( GitService, "retrieveUserGitRepositories", diff --git a/frontend/__tests__/components/features/org/org-selector.test.tsx b/frontend/__tests__/components/features/org/org-selector.test.tsx index 785d0631f8..74902d2da2 100644 --- a/frontend/__tests__/components/features/org/org-selector.test.tsx +++ b/frontend/__tests__/components/features/org/org-selector.test.tsx @@ -4,6 +4,11 @@ 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() }), @@ -32,8 +37,8 @@ describe("OrgSelector", () => { it("should select the first organization after orgs are loaded", async () => { vi.spyOn(organizationService, "getOrganizations").mockResolvedValue([ - { id: "1", name: "Personal Workspace", balance: 100 }, - { id: "2", name: "Acme Corp", balance: 1000 }, + MOCK_PERSONAL_ORG, + MOCK_TEAM_ORG_ACME, ]); renderOrgSelector(); @@ -47,9 +52,9 @@ describe("OrgSelector", () => { it("should show all options when the clear button is pressed", async () => { const user = userEvent.setup(); vi.spyOn(organizationService, "getOrganizations").mockResolvedValue([ - { id: "1", name: "Personal Workspace", balance: 100 }, - { id: "2", name: "Acme Corp", balance: 1000 }, - { id: "3", name: "Test Organization", balance: 500 }, + MOCK_PERSONAL_ORG, + MOCK_TEAM_ORG_ACME, + createMockOrganization("3", "Test Organization", 500), ]); renderOrgSelector(); diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx index 4844a778e1..dc5be687f5 100644 --- a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx @@ -3,7 +3,7 @@ import { renderWithProviders } from "test-utils"; import { createRoutesStub } from "react-router"; import { waitFor } from "@testing-library/react"; import { Sidebar } from "#/components/features/sidebar/sidebar"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; // These tests will now fail because the conversation panel is rendered through a portal // and technically not a child of the Sidebar component. 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 b4ed34bbb2..d65d1be286 100644 --- a/frontend/__tests__/components/features/user/user-context-menu.test.tsx +++ b/frontend/__tests__/components/features/user/user-context-menu.test.tsx @@ -6,7 +6,11 @@ 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 } from "#/mocks/org-handlers"; +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"; @@ -74,8 +78,7 @@ describe("UserContextMenu", () => { // Verify that navigation items are rendered (except organization-members/org which are filtered out) SAAS_NAV_ITEMS.filter( (item) => - item.to !== "/settings/organization-members" && - item.to !== "/settings/org", + item.to !== "/settings/org-members" && item.to !== "/settings/org", ).forEach((item) => { expect(screen.getByText(item.text)).toBeInTheDocument(); }); @@ -238,7 +241,7 @@ describe("UserContextMenu", () => { expect(integrationsLink).toHaveAttribute("href", "/settings/integrations"); }); - it("should navigate to /settings/organization-members when Manage Organization Members is clicked", async () => { + it("should navigate to /settings/org-members when Manage Organization Members is clicked", async () => { renderUserContextMenu({ type: "admin", onClose: vi.fn }); const manageOrganizationMembersButton = screen.getByText( @@ -247,7 +250,7 @@ describe("UserContextMenu", () => { await userEvent.click(manageOrganizationMembersButton); expect(navigateMock).toHaveBeenCalledExactlyOnceWith( - "/settings/organization-members", + "/settings/org-members", ); }); @@ -278,7 +281,7 @@ describe("UserContextMenu", () => { 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([ - { id: "2", name: "Acme Corp", balance: 1000 }, + MOCK_TEAM_ORG_ACME, ]); const onCloseMock = vi.fn(); @@ -303,7 +306,7 @@ describe("UserContextMenu", () => { 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([ - { id: "1", name: "Personal Workspace", balance: 100, is_personal: true }, + MOCK_PERSONAL_ORG, ]); vi.spyOn(organizationService, "getMe").mockResolvedValue({ id: "99", @@ -325,7 +328,7 @@ describe("UserContextMenu", () => { it("should not show Billing settings item when team org is selected", async () => { vi.spyOn(organizationService, "getOrganizations").mockResolvedValue([ - { id: "2", name: "Acme Corp", balance: 1000 }, + MOCK_TEAM_ORG_ACME, ]); vi.spyOn(organizationService, "getMe").mockResolvedValue({ id: "99", diff --git a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx index 6b4616ab12..099c50c194 100644 --- a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx +++ b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "test-utils"; import { createRoutesStub } from "react-router"; import { screen } from "@testing-library/react"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import { SettingsForm } from "#/components/shared/modals/settings/settings-form"; import { DEFAULT_SETTINGS } from "#/services/settings"; diff --git a/frontend/__tests__/conversation-websocket-handler.test.tsx b/frontend/__tests__/conversation-websocket-handler.test.tsx index f7d67d82b5..f922a8876c 100644 --- a/frontend/__tests__/conversation-websocket-handler.test.tsx +++ b/frontend/__tests__/conversation-websocket-handler.test.tsx @@ -1,12 +1,26 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { + describe, + it, + expect, + beforeAll, + beforeEach, + afterAll, + afterEach, +} from "vitest"; import { screen, waitFor, render, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { http, HttpResponse } from "msw"; import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; +import { useBrowserStore } from "#/stores/browser-store"; +import { useCommandStore } from "#/state/command-store"; import { createMockMessageEvent, createMockUserMessageEvent, createMockAgentErrorEvent, + createMockBrowserObservationEvent, + createMockBrowserNavigateActionEvent, + createMockExecuteBashActionEvent, + createMockExecuteBashObservationEvent, } from "#/mocks/mock-ws-helpers"; import { ConnectionStatusComponent, @@ -461,7 +475,7 @@ describe("Conversation WebSocket Handler", () => { ); // Create a test component that displays loading state - const HistoryLoadingComponent = () => { + function HistoryLoadingComponent() { const context = useConversationWebSocket(); const { events } = useEventStore(); @@ -474,7 +488,7 @@ describe("Conversation WebSocket Handler", () => {
{expectedEventCount}
); - }; + } // Render with WebSocket context renderWithWebSocketContext( @@ -484,7 +498,9 @@ describe("Conversation WebSocket Handler", () => { ); // Initially should be loading history - expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true"); + expect(screen.getByTestId("is-loading-history")).toHaveTextContent( + "true", + ); // Wait for all events to be received await waitFor(() => { @@ -523,7 +539,7 @@ describe("Conversation WebSocket Handler", () => { ); // Create a test component that displays loading state - const HistoryLoadingComponent = () => { + function HistoryLoadingComponent() { const context = useConversationWebSocket(); return ( @@ -533,7 +549,7 @@ describe("Conversation WebSocket Handler", () => { ); - }; + } // Render with WebSocket context renderWithWebSocketContext( @@ -583,7 +599,7 @@ describe("Conversation WebSocket Handler", () => { ); // Create a test component that displays loading state - const HistoryLoadingComponent = () => { + function HistoryLoadingComponent() { const context = useConversationWebSocket(); const { events } = useEventStore(); @@ -595,7 +611,7 @@ describe("Conversation WebSocket Handler", () => {
{events.length}
); - }; + } // Render with WebSocket context renderWithWebSocketContext( @@ -605,7 +621,9 @@ describe("Conversation WebSocket Handler", () => { ); // Initially should be loading history - expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true"); + expect(screen.getByTestId("is-loading-history")).toHaveTextContent( + "true", + ); // Wait for all events to be received await waitFor(() => { @@ -621,17 +639,133 @@ describe("Conversation WebSocket Handler", () => { }); }); - // 9. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation) - describe("Terminal I/O Integration", () => { - it("should append command to store when ExecuteBashAction event is received", async () => { - const { createMockExecuteBashActionEvent } = await import( - "#/mocks/mock-ws-helpers" + // 9. Browser State Tests (BrowserObservation) + describe("Browser State Integration", () => { + beforeEach(() => { + useBrowserStore.getState().reset(); + }); + + it("should update browser store with screenshot when BrowserObservation event is received", async () => { + // Create a mock BrowserObservation event with screenshot data + const mockBrowserObsEvent = createMockBrowserObservationEvent( + "base64-screenshot-data", + "Page loaded successfully", ); - const { useCommandStore } = await import("#/state/command-store"); - // Clear the command store before test + // Set up MSW to send the event when connection is established + mswServer.use( + wsLink.addEventListener("connection", ({ client, server }) => { + server.connect(); + // Send the mock event after connection + client.send(JSON.stringify(mockBrowserObsEvent)); + }), + ); + + // Render with WebSocket context + renderWithWebSocketContext(); + + // Wait for connection + await waitFor(() => { + expect(screen.getByTestId("connection-state")).toHaveTextContent( + "OPEN", + ); + }); + + // Wait for the browser store to be updated with screenshot + await waitFor(() => { + const { screenshotSrc } = useBrowserStore.getState(); + expect(screenshotSrc).toBe( + "data:image/png;base64,base64-screenshot-data", + ); + }); + }); + + it("should update browser store with URL when BrowserNavigateAction followed by BrowserObservation", async () => { + // Create mock events - action first, then observation + const mockBrowserActionEvent = createMockBrowserNavigateActionEvent( + "https://example.com/test-page", + ); + const mockBrowserObsEvent = createMockBrowserObservationEvent( + "base64-screenshot-data", + "Page loaded successfully", + ); + + // Set up MSW to send both events when connection is established + mswServer.use( + wsLink.addEventListener("connection", ({ client, server }) => { + server.connect(); + // Send action first, then observation + client.send(JSON.stringify(mockBrowserActionEvent)); + client.send(JSON.stringify(mockBrowserObsEvent)); + }), + ); + + // Render with WebSocket context + renderWithWebSocketContext(); + + // Wait for connection + await waitFor(() => { + expect(screen.getByTestId("connection-state")).toHaveTextContent( + "OPEN", + ); + }); + + // Wait for the browser store to be updated with both screenshot and URL + await waitFor(() => { + const { screenshotSrc, url } = useBrowserStore.getState(); + expect(screenshotSrc).toBe( + "data:image/png;base64,base64-screenshot-data", + ); + expect(url).toBe("https://example.com/test-page"); + }); + }); + + it("should not update browser store when BrowserObservation has no screenshot data", async () => { + const initialScreenshot = useBrowserStore.getState().screenshotSrc; + + // Create a mock BrowserObservation event WITHOUT screenshot data + const mockBrowserObsEvent = createMockBrowserObservationEvent( + null, // no screenshot + "Browser action completed", + ); + + // Set up MSW to send the event when connection is established + mswServer.use( + wsLink.addEventListener("connection", ({ client, server }) => { + server.connect(); + // Send the mock event after connection + client.send(JSON.stringify(mockBrowserObsEvent)); + }), + ); + + // Render with WebSocket context + renderWithWebSocketContext(); + + // Wait for connection + await waitFor(() => { + expect(screen.getByTestId("connection-state")).toHaveTextContent( + "OPEN", + ); + }); + + // Give some time for any potential updates + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + + // Screenshot should remain unchanged (empty/initial value) + const { screenshotSrc } = useBrowserStore.getState(); + expect(screenshotSrc).toBe(initialScreenshot); + }); + }); + + // 10. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation) + describe("Terminal I/O Integration", () => { + beforeEach(() => { useCommandStore.getState().clearTerminal(); + }); + it("should append command to store when ExecuteBashAction event is received", async () => { // Create a mock ExecuteBashAction event const mockBashActionEvent = createMockExecuteBashActionEvent("npm test"); @@ -667,14 +801,6 @@ describe("Conversation WebSocket Handler", () => { }); it("should append output to store when ExecuteBashObservation event is received", async () => { - const { createMockExecuteBashObservationEvent } = await import( - "#/mocks/mock-ws-helpers" - ); - const { useCommandStore } = await import("#/state/command-store"); - - // Clear the command store before test - useCommandStore.getState().clearTerminal(); - // Create a mock ExecuteBashObservation event const mockBashObservationEvent = createMockExecuteBashObservationEvent( "PASS tests/example.test.js\n ✓ should work (2 ms)", diff --git a/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx b/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx index 29fdb99273..d2a7c798c4 100644 --- a/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx +++ b/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx @@ -1,7 +1,7 @@ import { renderHook, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; describe("useSaveSettings", () => { diff --git a/frontend/__tests__/hooks/use-websocket.test.ts b/frontend/__tests__/hooks/use-websocket.test.ts index cb76fbcc90..50e8e70571 100644 --- a/frontend/__tests__/hooks/use-websocket.test.ts +++ b/frontend/__tests__/hooks/use-websocket.test.ts @@ -1,3 +1,11 @@ +/** + * TODO: Fix flaky WebSocket tests (https://github.com/OpenHands/OpenHands/issues/11944) + * + * Several tests in this file are skipped because they fail intermittently in CI + * but pass locally. The SUSPECTED root cause is that `wsLink.broadcast()` sends messages + * to ALL connected clients across all tests, causing cross-test contamination + * when tests run in parallel with Vitest v4. + */ import { renderHook, waitFor } from "@testing-library/react"; import { describe, @@ -51,7 +59,7 @@ describe("useWebSocket", () => { expect(result.current.socket).toBeTruthy(); }); - it("should handle incoming messages correctly", async () => { + it.skip("should handle incoming messages correctly", async () => { const { result } = renderHook(() => useWebSocket("ws://acme.com/ws")); // Wait for connection to be established @@ -114,7 +122,7 @@ describe("useWebSocket", () => { expect(result.current.socket).toBeTruthy(); }); - it("should close the WebSocket connection on unmount", async () => { + it.skip("should close the WebSocket connection on unmount", async () => { const { result, unmount } = renderHook(() => useWebSocket("ws://acme.com/ws"), ); @@ -204,7 +212,7 @@ describe("useWebSocket", () => { }); }); - it("should call onMessage handler when WebSocket receives a message", async () => { + it.skip("should call onMessage handler when WebSocket receives a message", async () => { const onMessageSpy = vi.fn(); const options = { onMessage: onMessageSpy }; @@ -271,7 +279,7 @@ describe("useWebSocket", () => { expect(onErrorSpy).toHaveBeenCalled(); }); - it("should provide sendMessage function to send messages to WebSocket", async () => { + it.skip("should provide sendMessage function to send messages to WebSocket", async () => { const { result } = renderHook(() => useWebSocket("ws://acme.com/ws")); // Wait for connection to be established diff --git a/frontend/__tests__/routes/_oh.test.tsx b/frontend/__tests__/routes/_oh.test.tsx index 7737eaa7f7..0b76148ad6 100644 --- a/frontend/__tests__/routes/_oh.test.tsx +++ b/frontend/__tests__/routes/_oh.test.tsx @@ -10,7 +10,7 @@ import MainApp from "#/routes/root-layout"; import i18n from "#/i18n"; import OptionService from "#/api/option-service/option-service.api"; import * as CaptureConsent from "#/utils/handle-capture-consent"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import * as ToastHandlers from "#/utils/custom-toast-handlers"; describe("frontend/routes/_oh", () => { diff --git a/frontend/__tests__/routes/app-settings.test.tsx b/frontend/__tests__/routes/app-settings.test.tsx index 44dacce2fb..038cb94c52 100644 --- a/frontend/__tests__/routes/app-settings.test.tsx +++ b/frontend/__tests__/routes/app-settings.test.tsx @@ -3,7 +3,7 @@ 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 SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; import { AvailableLanguages } from "#/i18n"; import * as CaptureConsent from "#/utils/handle-capture-consent"; diff --git a/frontend/__tests__/routes/git-settings.test.tsx b/frontend/__tests__/routes/git-settings.test.tsx index 0c3f77bed0..9f1008ce3c 100644 --- a/frontend/__tests__/routes/git-settings.test.tsx +++ b/frontend/__tests__/routes/git-settings.test.tsx @@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event"; import i18next from "i18next"; import { I18nextProvider } from "react-i18next"; import GitSettingsScreen from "#/routes/git-settings"; -import SettingsService from "#/settings-service/settings-service.api"; +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"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; diff --git a/frontend/__tests__/routes/home-screen.test.tsx b/frontend/__tests__/routes/home-screen.test.tsx index a515f670be..5ac746e924 100644 --- a/frontend/__tests__/routes/home-screen.test.tsx +++ b/frontend/__tests__/routes/home-screen.test.tsx @@ -6,7 +6,7 @@ import { createRoutesStub } from "react-router"; import { createAxiosNotFoundErrorObject } from "test-utils"; import HomeScreen from "#/routes/home"; import { GitRepository } from "#/types/git"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import GitService from "#/api/git-service/git-service.api"; import OptionService from "#/api/option-service/option-service.api"; import MainApp from "#/routes/root-layout"; diff --git a/frontend/__tests__/routes/llm-settings.test.tsx b/frontend/__tests__/routes/llm-settings.test.tsx index f826b20f45..adfca6b40a 100644 --- a/frontend/__tests__/routes/llm-settings.test.tsx +++ b/frontend/__tests__/routes/llm-settings.test.tsx @@ -3,13 +3,14 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; import LlmSettingsScreen from "#/routes/llm-settings"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import { MOCK_DEFAULT_USER_SETTINGS, resetTestHandlersMockSettings, } from "#/mocks/handlers"; import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set"; import * as ToastHandlers from "#/utils/custom-toast-handlers"; +import OptionService from "#/api/option-service/option-service.api"; // Mock react-router hooks const mockUseSearchParams = vi.fn(); @@ -255,6 +256,210 @@ describe("Content", () => { }); it.todo("should render an indicator if the llm api key is set"); + + 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", + }); + + renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); + + const basicForm = screen.getByTestId("llm-settings-form-basic"); + const provider = within(basicForm).getByTestId("llm-provider-input"); + + // Verify OpenHands is selected by default + await waitFor(() => { + expect(provider).toHaveValue("OpenHands"); + }); + + // API key input should not be visible when OpenHands provider is selected in SaaS mode + expect( + within(basicForm).queryByTestId("llm-api-key-input"), + ).not.toBeInTheDocument(); + expect( + within(basicForm).queryByTestId("llm-api-key-help-anchor"), + ).not.toBeInTheDocument(); + }); + + 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", + }); + + renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); + + const basicForm = screen.getByTestId("llm-settings-form-basic"); + const provider = within(basicForm).getByTestId("llm-provider-input"); + + // Select OpenAI provider + await userEvent.click(provider); + const providerOption = screen.getByText("OpenAI"); + await userEvent.click(providerOption); + + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); + + // API key input should be visible when non-OpenHands provider is selected in SaaS mode + expect( + within(basicForm).getByTestId("llm-api-key-input"), + ).toBeInTheDocument(); + expect( + within(basicForm).getByTestId("llm-api-key-help-anchor"), + ).toBeInTheDocument(); + }); + + 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", + }); + + renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); + + const basicForm = screen.getByTestId("llm-settings-form-basic"); + const provider = within(basicForm).getByTestId("llm-provider-input"); + + // Verify OpenHands is selected by default + await waitFor(() => { + expect(provider).toHaveValue("OpenHands"); + }); + + // API key input should be visible when OSS mode is enabled (even with OpenHands provider) + expect( + within(basicForm).getByTestId("llm-api-key-input"), + ).toBeInTheDocument(); + expect( + within(basicForm).getByTestId("llm-api-key-help-anchor"), + ).toBeInTheDocument(); + }); + + 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", + }); + + renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); + + const basicForm = screen.getByTestId("llm-settings-form-basic"); + const provider = within(basicForm).getByTestId("llm-provider-input"); + + // Select OpenAI provider + await userEvent.click(provider); + const providerOption = screen.getByText("OpenAI"); + await userEvent.click(providerOption); + + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); + + // API key input should be visible when OSS mode is enabled + expect( + within(basicForm).getByTestId("llm-api-key-input"), + ).toBeInTheDocument(); + expect( + within(basicForm).getByTestId("llm-api-key-help-anchor"), + ).toBeInTheDocument(); + }); + + 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", + }); + + renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); + + const basicForm = screen.getByTestId("llm-settings-form-basic"); + const provider = within(basicForm).getByTestId("llm-provider-input"); + + // Start with OpenAI provider + await userEvent.click(provider); + const openAIOption = screen.getByText("OpenAI"); + await userEvent.click(openAIOption); + + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); + + // API key input should be visible with OpenAI + expect( + within(basicForm).getByTestId("llm-api-key-input"), + ).toBeInTheDocument(); + + // Switch to OpenHands provider + await userEvent.click(provider); + const openHandsOption = screen.getByText("OpenHands"); + await userEvent.click(openHandsOption); + + await waitFor(() => { + expect(provider).toHaveValue("OpenHands"); + }); + + // API key input should now be hidden + expect( + within(basicForm).queryByTestId("llm-api-key-input"), + ).not.toBeInTheDocument(); + expect( + within(basicForm).queryByTestId("llm-api-key-help-anchor"), + ).not.toBeInTheDocument(); + }); + + 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", + }); + + renderLlmSettingsScreen(); + await screen.findByTestId("llm-settings-screen"); + + const basicForm = screen.getByTestId("llm-settings-form-basic"); + const provider = within(basicForm).getByTestId("llm-provider-input"); + + // Verify OpenHands is selected by default + await waitFor(() => { + expect(provider).toHaveValue("OpenHands"); + }); + + // API key input should be hidden with OpenHands + expect( + within(basicForm).queryByTestId("llm-api-key-input"), + ).not.toBeInTheDocument(); + + // Switch to OpenAI provider + await userEvent.click(provider); + const openAIOption = screen.getByText("OpenAI"); + await userEvent.click(openAIOption); + + await waitFor(() => { + expect(provider).toHaveValue("OpenAI"); + }); + + // API key input should now be visible + expect( + within(basicForm).getByTestId("llm-api-key-input"), + ).toBeInTheDocument(); + expect( + within(basicForm).getByTestId("llm-api-key-help-anchor"), + ).toBeInTheDocument(); + }); + }); }); describe("Form submission", () => { diff --git a/frontend/__tests__/routes/manage-org.test.tsx b/frontend/__tests__/routes/manage-org.test.tsx index 11257e39a6..30a15f6323 100644 --- a/frontend/__tests__/routes/manage-org.test.tsx +++ b/frontend/__tests__/routes/manage-org.test.tsx @@ -102,6 +102,7 @@ describe("Manage Org Route", () => { vi.clearAllMocks(); // Reset organization mock data to ensure clean state between tests resetOrgMockData(); + vi.clearAllMocks(); }); it("should render the available credits", async () => { @@ -193,6 +194,315 @@ 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 = 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"); + }); + + it("should display correct placeholder text", async () => { + await openAddCreditsModal(); + + const amountInput = screen.getByTestId("amount-input"); + expect(amountInput).toHaveAttribute( + "placeholder", + "PAYMENT$SPECIFY_AMOUNT_USD", + ); + }); + }); + + 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/__tests__/routes/manage-organization-members.test.tsx b/frontend/__tests__/routes/manage-organization-members.test.tsx index 96f183ae20..b1222ecb74 100644 --- a/frontend/__tests__/routes/manage-organization-members.test.tsx +++ b/frontend/__tests__/routes/manage-organization-members.test.tsx @@ -35,7 +35,7 @@ const RouteStub = createRoutesStub([ children: [ { Component: ManageOrganizationMembersWithPortalRoot, - path: "/settings/organization-members", + path: "/settings/org-members", }, { Component: () =>
, @@ -79,7 +79,7 @@ describe("Manage Organization Members Route", () => { }); const renderManageOrganizationMembers = () => - render(, { + render(, { wrapper: ({ children }) => ( {children} diff --git a/frontend/__tests__/routes/secrets-settings.test.tsx b/frontend/__tests__/routes/secrets-settings.test.tsx index 1ffccc29ff..9b5c315f92 100644 --- a/frontend/__tests__/routes/secrets-settings.test.tsx +++ b/frontend/__tests__/routes/secrets-settings.test.tsx @@ -6,7 +6,7 @@ import { createRoutesStub, Outlet } from "react-router"; import SecretsSettingsScreen from "#/routes/secrets-settings"; import { SecretsService } from "#/api/secrets-service"; import { GetSecretsResponse } from "#/api/secrets-service.types"; -import SettingsService from "#/settings-service/settings-service.api"; +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"; diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx index 6d1ee33f03..47fdc5f29b 100644 --- a/frontend/__tests__/routes/settings.test.tsx +++ b/frontend/__tests__/routes/settings.test.tsx @@ -5,6 +5,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import SettingsScreen, { clientLoader } from "#/routes/settings"; import OptionService from "#/api/option-service/option-service.api"; import { organizationService } from "#/api/organization-service/organization-service.api"; +import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers"; // Mock the i18next hook vi.mock("react-i18next", async () => { @@ -207,12 +208,7 @@ describe("Settings Screen", () => { 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([ - { - id: "1", - name: "Personal Workspace", - balance: 100, - is_personal: true, - }, + MOCK_PERSONAL_ORG, ]); vi.spyOn(organizationService, "getMe").mockResolvedValue({ id: "99", @@ -240,7 +236,7 @@ describe("Settings Screen", () => { mockQueryClient.setQueryData(["config"], { APP_MODE: "saas" }); vi.spyOn(organizationService, "getOrganizations").mockResolvedValue([ - { id: "2", name: "Acme Corp", balance: 1000 }, + MOCK_TEAM_ORG_ACME, ]); vi.spyOn(organizationService, "getMe").mockResolvedValue({ id: "99", @@ -263,26 +259,11 @@ describe("Settings Screen", () => { 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"], - [ - { - id: "1", - name: "Personal Workspace", - balance: 100, - is_personal: true, - }, - ], - ); + mockQueryClient.setQueryData(["organizations"], [MOCK_PERSONAL_ORG]); mockQueryClient.setQueryData(["selected_organization"], "1"); vi.spyOn(organizationService, "getOrganizations").mockResolvedValue([ - { - id: "1", - name: "Personal Workspace", - balance: 100, - is_personal: true, - }, + MOCK_PERSONAL_ORG, ]); vi.spyOn(organizationService, "getMe").mockResolvedValue({ id: "99", @@ -303,26 +284,11 @@ describe("Settings Screen", () => { it("should not allow direct URL access to /settings/organization-members when personal org is selected", async () => { // Set up orgs in query client so clientLoader can access them - mockQueryClient.setQueryData( - ["organizations"], - [ - { - id: "1", - name: "Personal Workspace", - balance: 100, - is_personal: true, - }, - ], - ); + mockQueryClient.setQueryData(["organizations"], [MOCK_PERSONAL_ORG]); mockQueryClient.setQueryData(["selected_organization"], "1"); vi.spyOn(organizationService, "getOrganizations").mockResolvedValue([ - { - id: "1", - name: "Personal Workspace", - balance: 100, - is_personal: true, - }, + MOCK_PERSONAL_ORG, ]); vi.spyOn(organizationService, "getMe").mockResolvedValue({ id: "99", @@ -343,14 +309,11 @@ describe("Settings Screen", () => { 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"], - [{ id: "2", name: "Acme Corp", balance: 1000 }], - ); + mockQueryClient.setQueryData(["organizations"], [MOCK_TEAM_ORG_ACME]); mockQueryClient.setQueryData(["selected_organization"], "2"); vi.spyOn(organizationService, "getOrganizations").mockResolvedValue([ - { id: "2", name: "Acme Corp", balance: 1000 }, + MOCK_TEAM_ORG_ACME, ]); vi.spyOn(organizationService, "getMe").mockResolvedValue({ id: "99", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 17e12f5cef..78ce21d4c8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,17 +12,17 @@ "@heroui/use-infinite-scroll": "^2.2.12", "@microlink/react-json-view": "^1.26.2", "@monaco-editor/react": "^4.7.0-rc.0", - "@posthog/react": "^1.5.0", - "@react-router/node": "^7.9.6", - "@react-router/serve": "^7.9.6", + "@posthog/react": "^1.5.2", + "@react-router/node": "^7.10.1", + "@react-router/serve": "^7.10.1", "@react-types/shared": "^3.32.0", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.5.3", "@tailwindcss/postcss": "^4.1.17", "@tailwindcss/vite": "^4.1.17", - "@tanstack/react-query": "^5.90.11", + "@tanstack/react-query": "^5.90.12", "@uidotdev/usehooks": "^2.4.1", - "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react": "^5.1.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.4.0", "axios": "^1.13.2", @@ -32,22 +32,22 @@ "downshift": "^9.0.12", "eslint-config-airbnb-typescript": "^18.0.0", "framer-motion": "^12.23.25", - "i18next": "^25.7.1", + "i18next": "^25.7.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.32", "jose": "^6.1.3", - "lucide-react": "^0.555.0", - "monaco-editor": "^0.53.0", - "posthog-js": "^1.299.0", + "lucide-react": "^0.556.0", + "monaco-editor": "^0.55.1", + "posthog-js": "^1.302.2", "react": "^19.2.0", "react-dom": "^19.2.0", "react-highlight": "^0.15.0", "react-hot-toast": "^2.6.0", - "react-i18next": "^16.3.5", + "react-i18next": "^16.4.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", - "react-router": "^7.9.6", + "react-router": "^7.10.1", "react-syntax-highlighter": "^16.1.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", @@ -55,7 +55,7 @@ "socket.io-client": "^4.8.1", "tailwind-merge": "^3.4.0", "tailwind-scrollbar": "^4.0.2", - "vite": "^7.2.6", + "vite": "^7.2.7", "web-vitals": "^5.1.0", "ws": "^8.18.2", "zustand": "^5.0.9" @@ -66,7 +66,7 @@ "@babel/types": "^7.28.2", "@mswjs/socket.io-binding": "^0.2.0", "@playwright/test": "^1.57.0", - "@react-router/dev": "^7.9.6", + "@react-router/dev": "^7.10.1", "@tailwindcss/typography": "^0.5.19", "@tanstack/eslint-plugin-query": "^5.91.0", "@testing-library/dom": "^10.4.1", @@ -96,7 +96,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-unused-imports": "^4.2.0", "husky": "^9.1.7", - "jsdom": "^27.2.0", + "jsdom": "^27.3.0", "lint-staged": "^16.2.7", "msw": "^2.6.6", "prettier": "^3.7.3", @@ -112,11 +112,10 @@ } }, "node_modules/@acemir/cssom": { - "version": "0.9.24", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.24.tgz", - "integrity": "sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==", - "dev": true, - "license": "MIT" + "version": "0.9.28", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.28.tgz", + "integrity": "sha512-LuS6IVEivI75vKN8S04qRD+YySP0RmU/cV8UNukhQZvprxF+76Z43TNo/a08eCodaGhT1Us8etqS1ZRY9/Or0A==", + "dev": true }, "node_modules/@adobe/css-tools": { "version": "4.4.4", @@ -142,7 +141,6 @@ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz", "integrity": "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==", "dev": true, - "license": "MIT", "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", @@ -156,23 +154,21 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "dev": true, - "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.5", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.5.tgz", - "integrity": "sha512-Eks6dY8zau4m4wNRQjRVaKQRTalNcPcBvU1ZQ35w5kKRk1gUeNCkVLsRiATurjASTp3TKM4H10wsI50nx3NZdw==", + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", "dev": true, - "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.2" + "lru-cache": "^11.2.4" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { @@ -180,7 +176,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "dev": true, - "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -189,8 +184,7 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -700,7 +694,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" } @@ -720,7 +713,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "engines": { "node": ">=18" }, @@ -744,7 +736,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" @@ -772,7 +763,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "engines": { "node": ">=18" }, @@ -781,9 +771,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.20.tgz", - "integrity": "sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", + "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", "dev": true, "funding": [ { @@ -795,9 +785,11 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", "engines": { "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, "node_modules/@csstools/css-tokenizer": { @@ -815,7 +807,6 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "engines": { "node": ">=18" } @@ -3388,19 +3379,17 @@ "license": "MIT" }, "node_modules/@posthog/core": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.7.0.tgz", - "integrity": "sha512-d6ZV4grpzeH/6/LP8quMVpSjY1puRkrqfwcPvGRKUAX7tb7YHyp/zMiTDuJmOFbpUxAMBXH5nDwcPiyCY2WGzA==", - "license": "MIT", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.7.1.tgz", + "integrity": "sha512-kjK0eFMIpKo9GXIbts8VtAknsoZ18oZorANdtuTj1CbgS28t4ZVq//HAWhnxEuXRTrtkd+SUJ6Ux3j2Af8NCuA==", "dependencies": { "cross-spawn": "^7.0.6" } }, "node_modules/@posthog/react": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@posthog/react/-/react-1.5.1.tgz", - "integrity": "sha512-XdiJEA3EWXmZzSa+Qou7SSJ/MiC08pbCw2DPtx4w8OyvPLpn5D2v6eO3ASieC94wP7zyrOFKh3+q4LYVas2+7A==", - "license": "MIT", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@posthog/react/-/react-1.5.2.tgz", + "integrity": "sha512-KHdXbV1yba7Y2l8BVmwXlySWxqKVLNQ5ZiVvWOf7r3Eo7GIFxCM4CaNK/z83kKWn8KTskmKy7AGF6Hl6INWK3g==", "peerDependencies": { "@types/react": ">=16.8.0", "posthog-js": ">=1.257.2", @@ -4121,11 +4110,10 @@ } }, "node_modules/@react-router/dev": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.10.0.tgz", - "integrity": "sha512-3UgkV0N5lp3+Ol3q64L4ZHgPXv2XA4KHJ59MVLSK2prokrOrPaYvqbdx40r602M+hRZp/u04ln2A6cOfBW6kxA==", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.10.1.tgz", + "integrity": "sha512-kap9O8rTN6b3vxjd+0SGjhm5vqiAZHMmOX1Hc7Y4KXRVVdusn+0+hxs44cDSfbW6Z6fCLw6GXXe0Kr+DJIRezw==", "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", @@ -4134,7 +4122,7 @@ "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", - "@react-router/node": "7.10.0", + "@react-router/node": "7.10.1", "@remix-run/node-fetch-server": "^0.9.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", @@ -4153,7 +4141,7 @@ "react-refresh": "^0.14.0", "semver": "^7.3.7", "tinyglobby": "^0.2.14", - "valibot": "^1.1.0", + "valibot": "^1.2.0", "vite-node": "^3.2.2" }, "bin": { @@ -4163,9 +4151,9 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@react-router/serve": "^7.10.0", + "@react-router/serve": "^7.10.1", "@vitejs/plugin-rsc": "*", - "react-router": "^7.10.0", + "react-router": "^7.10.1", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" @@ -4198,33 +4186,10 @@ "node": ">=6" } }, - "node_modules/@react-router/express": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.10.0.tgz", - "integrity": "sha512-3cBJ2cyHn5J+wSNFn+XdNSpXVAlQ+nbj7CMa3OsiEpFb+d0GLthirvSESqRjX2Eid94xNHICqKpYS9bR4QqIxg==", - "license": "MIT", - "dependencies": { - "@react-router/node": "7.10.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "express": "^4.17.1 || ^5", - "react-router": "7.10.0", - "typescript": "^5.1.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@react-router/node": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.10.0.tgz", - "integrity": "sha512-pff3Xz3gASrIUUX54QdlPzasdN9XRLnzoFEwUVsH5y2sZ6vijQdjZExLS6aQhPiuUr/uVPwN2WngO0Ryfrxulg==", - "license": "MIT", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.10.1.tgz", + "integrity": "sha512-RLmjlR1zQu+ve8ibI0lu91pJrXGcmfkvsrQl7z/eTc5V5FZgl0OvQVWL5JDWBlBZyzdLMQQekUOX5WcPhCP1FQ==", "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, @@ -4232,7 +4197,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "react-router": "7.10.0", + "react-router": "7.10.1", "typescript": "^5.1.0" }, "peerDependenciesMeta": { @@ -4242,14 +4207,13 @@ } }, "node_modules/@react-router/serve": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.10.0.tgz", - "integrity": "sha512-tgdbw1lmDkzF3gCMj//iNklgUrYHUxz35rj0sbyLeti8K2gVsNxaZWyt5omanFgkeZ7WYfi0wzLHviqxl228eA==", - "license": "MIT", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.10.1.tgz", + "integrity": "sha512-qYco7sFpbRgoKJKsCgJmFBQwaLVsLv255K8vbPodnXe13YBEzV/ugIqRCYVz2hghvlPiEKgaHh2On0s/5npn6w==", "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", - "@react-router/express": "7.10.0", - "@react-router/node": "7.10.0", + "@react-router/express": "7.10.1", + "@react-router/node": "7.10.1", "compression": "^1.7.4", "express": "^4.19.2", "get-port": "5.1.1", @@ -4263,7 +4227,28 @@ "node": ">=20.0.0" }, "peerDependencies": { - "react-router": "7.10.0" + "react-router": "7.10.1" + } + }, + "node_modules/@react-router/serve/node_modules/@react-router/express": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.10.1.tgz", + "integrity": "sha512-O7xjg6wWHfrsnPyVWgQG+tCamIE09SqLqtHwa1tAFzKPjcDpCw4S4+/OkJvNXLtBL60H3VhZ1r2OQgXBgGOMpw==", + "dependencies": { + "@react-router/node": "7.10.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "express": "^4.17.1 || ^5", + "react-router": "7.10.1", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@react-stately/calendar": { @@ -4915,10 +4900,9 @@ "license": "MIT" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.47", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", - "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", - "license": "MIT" + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==" }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", @@ -5750,6 +5734,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.6.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.6.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", @@ -5840,22 +5878,20 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.11", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz", - "integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==", - "license": "MIT", + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.90.11", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz", - "integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==", - "license": "MIT", + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", "dependencies": { - "@tanstack/query-core": "5.90.11" + "@tanstack/query-core": "5.90.12" }, "funding": { "type": "github", @@ -6177,10 +6213,10 @@ "license": "MIT" }, "node_modules/@types/trusted-types": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz", - "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", - "license": "MIT" + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -6615,15 +6651,14 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", - "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", - "license": "MIT", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.47", + "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -6819,7 +6854,6 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -6832,7 +6866,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6973,8 +7006,7 @@ "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, "node_modules/array-includes": { "version": "3.1.9", @@ -7334,7 +7366,6 @@ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, - "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" } @@ -7343,7 +7374,6 @@ "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -7367,7 +7397,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -7375,8 +7404,7 @@ "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/brace-expansion": { "version": "2.0.2", @@ -7943,7 +7971,6 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" }, @@ -7955,7 +7982,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -7970,7 +7996,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -7978,8 +8003,7 @@ "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" }, "node_modules/core-js": { "version": "3.47.0", @@ -8065,7 +8089,6 @@ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, - "license": "MIT", "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" @@ -8095,14 +8118,13 @@ } }, "node_modules/cssstyle": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", - "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.4.tgz", + "integrity": "sha512-KyOS/kJMEq5O9GdPnaf82noigg5X5DYn0kZPJTaAsCUaBizp6Xa1y9D4Qoqf/JazEXWuruErHgVXwjN5391ZJw==", "dev": true, - "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "@asamuzakjp/css-color": "^4.1.0", + "@csstools/css-syntax-patches-for-csstree": "1.0.14", "css-tree": "^3.1.0" }, "engines": { @@ -8334,7 +8356,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -8395,6 +8416,14 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -8459,7 +8488,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -8813,8 +8841,7 @@ "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "node_modules/escape-string-regexp": { "version": "4.0.0", @@ -9488,7 +9515,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -9527,7 +9553,6 @@ "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9573,7 +9598,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -9581,8 +9605,7 @@ "node_modules/express/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/exsolve": { "version": "1.0.8", @@ -9714,7 +9737,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -9732,7 +9754,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -9740,8 +9761,7 @@ "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/find-up": { "version": "5.0.0", @@ -9855,7 +9875,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -9905,7 +9924,6 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -10470,7 +10488,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -10531,9 +10548,9 @@ } }, "node_modules/i18next": { - "version": "25.7.1", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.1.tgz", - "integrity": "sha512-XbTnkh1yCZWSAZGnA9xcQfHcYNgZs2cNxm+c6v1Ma9UAUGCeJPplRe1ILia6xnDvXBjk0uXU+Z8FYWhA19SKFw==", + "version": "25.7.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.2.tgz", + "integrity": "sha512-58b4kmLpLv1buWUEwegMDUqZVR5J+rT+WTRFaBGL7lxDuJQQ0NrJFrq+eT2N94aYVR1k1Sr13QITNOL88tZCuw==", "funding": [ { "type": "individual", @@ -10548,7 +10565,6 @@ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" } ], - "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -10583,7 +10599,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -10703,7 +10718,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", "engines": { "node": ">= 0.10" } @@ -11054,8 +11068,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/is-regex": { "version": "1.2.1", @@ -11334,15 +11347,14 @@ } }, "node_modules/jsdom": { - "version": "27.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", - "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "version": "27.3.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz", + "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, - "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.23", - "@asamuzakjp/dom-selector": "^6.7.4", - "cssstyle": "^5.3.3", + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", @@ -11943,10 +11955,9 @@ } }, "node_modules/lucide-react": { - "version": "0.555.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz", - "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==", - "license": "ISC", + "version": "0.556.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.556.0.tgz", + "integrity": "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -12008,6 +12019,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -12317,14 +12339,12 @@ "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" + "dev": true }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -12333,7 +12353,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -12352,7 +12371,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -12938,7 +12956,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", "bin": { "mime": "cli.js" }, @@ -13026,12 +13043,12 @@ } }, "node_modules/monaco-editor": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz", - "integrity": "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==", - "license": "MIT", + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "dependencies": { - "@types/trusted-types": "^1.0.6" + "dompurify": "3.2.7", + "marked": "14.0.0" } }, "node_modules/morgan": { @@ -13478,7 +13495,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -13683,7 +13699,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -13727,8 +13742,7 @@ "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "node_modules/path-type": { "version": "4.0.0", @@ -13890,12 +13904,11 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.300.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.300.0.tgz", - "integrity": "sha512-FpeD4pkpg9i/3F5xpeX+CZeq3R3BaLyFDkzb4sOjpUIW1AAVuOEqPTxnWIess6C3pOw1u3mowq0WSeh1PmrqfA==", - "license": "SEE LICENSE IN LICENSE", + "version": "1.302.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.302.2.tgz", + "integrity": "sha512-4voih22zQe7yHA7DynlQ3B7kgzJOaKIjzV7K3jJ2Qf+UDXd1ZgO7xYmLWYVtuKEvD1OXHbKk/fPhUTZeHEWpBw==", "dependencies": { - "@posthog/core": "1.7.0", + "@posthog/core": "1.7.1", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", @@ -14045,7 +14058,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -14110,7 +14122,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -14119,7 +14130,6 @@ "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -14218,10 +14228,9 @@ } }, "node_modules/react-i18next": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz", - "integrity": "sha512-F7Kglc+T0aE6W2rO5eCAFBEuWRpNb5IFmXOYEgztjZEuiuSLTe/xBIEG6Q3S0fbl8GXMNo+Q7gF8bpokFNWJww==", - "license": "MIT", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.4.0.tgz", + "integrity": "sha512-bxVeBA8Ky2UeItNhF4JRxHCFIrpEJHGFG/mOAa4CR0JkqaDEYSLmlEgmC4Os63SBlZ+E5U0YyrNJOSVl2mtVqQ==", "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1", @@ -14303,10 +14312,9 @@ } }, "node_modules/react-router": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.0.tgz", - "integrity": "sha512-FVyCOH4IZ0eDDRycODfUqoN8ZSR2LbTvtx6RPsBgzvJ8xAXlMZNCrOFpu+jb8QbtZnpAd/cEki2pwE848pNGxw==", - "license": "MIT", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", + "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -14558,7 +14566,6 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -14875,7 +14882,6 @@ "version": "0.19.1", "resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz", "integrity": "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==", - "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -14899,7 +14905,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -14907,14 +14912,12 @@ "node_modules/send/node_modules/debug/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/send/node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -14930,7 +14933,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -14939,7 +14941,6 @@ "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "license": "MIT", "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", @@ -14954,7 +14955,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -14962,14 +14962,12 @@ "node_modules/serve-static/node_modules/debug/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/serve-static/node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -14985,7 +14983,6 @@ "version": "0.19.0", "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -15009,7 +15006,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -15018,7 +15014,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -15081,8 +15076,7 @@ "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -15971,7 +15965,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", "engines": { "node": ">=0.6" } @@ -16127,7 +16120,6 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -16345,7 +16337,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -16465,7 +16456,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", "engines": { "node": ">= 0.4.0" } @@ -16523,10 +16513,9 @@ } }, "node_modules/vite": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", - "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", - "license": "MIT", + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", diff --git a/frontend/package.json b/frontend/package.json index 9e20dca7bb..a0b1601f25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,17 +11,17 @@ "@heroui/use-infinite-scroll": "^2.2.12", "@microlink/react-json-view": "^1.26.2", "@monaco-editor/react": "^4.7.0-rc.0", - "@posthog/react": "^1.5.0", - "@react-router/node": "^7.9.6", - "@react-router/serve": "^7.9.6", + "@posthog/react": "^1.5.2", + "@react-router/node": "^7.10.1", + "@react-router/serve": "^7.10.1", "@react-types/shared": "^3.32.0", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.5.3", "@tailwindcss/postcss": "^4.1.17", "@tailwindcss/vite": "^4.1.17", - "@tanstack/react-query": "^5.90.11", + "@tanstack/react-query": "^5.90.12", "@uidotdev/usehooks": "^2.4.1", - "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react": "^5.1.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.4.0", "axios": "^1.13.2", @@ -31,22 +31,22 @@ "downshift": "^9.0.12", "eslint-config-airbnb-typescript": "^18.0.0", "framer-motion": "^12.23.25", - "i18next": "^25.7.1", + "i18next": "^25.7.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.32", "jose": "^6.1.3", - "lucide-react": "^0.555.0", - "monaco-editor": "^0.53.0", - "posthog-js": "^1.299.0", + "lucide-react": "^0.556.0", + "monaco-editor": "^0.55.1", + "posthog-js": "^1.302.2", "react": "^19.2.0", "react-dom": "^19.2.0", "react-highlight": "^0.15.0", "react-hot-toast": "^2.6.0", - "react-i18next": "^16.3.5", + "react-i18next": "^16.4.0", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", - "react-router": "^7.9.6", + "react-router": "^7.10.1", "react-syntax-highlighter": "^16.1.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", @@ -54,7 +54,7 @@ "socket.io-client": "^4.8.1", "tailwind-merge": "^3.4.0", "tailwind-scrollbar": "^4.0.2", - "vite": "^7.2.6", + "vite": "^7.2.7", "web-vitals": "^5.1.0", "ws": "^8.18.2", "zustand": "^5.0.9" @@ -97,7 +97,7 @@ "@babel/types": "^7.28.2", "@mswjs/socket.io-binding": "^0.2.0", "@playwright/test": "^1.57.0", - "@react-router/dev": "^7.9.6", + "@react-router/dev": "^7.10.1", "@tailwindcss/typography": "^0.5.19", "@tanstack/eslint-plugin-query": "^5.91.0", "@testing-library/dom": "^10.4.1", @@ -127,7 +127,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-unused-imports": "^4.2.0", "husky": "^9.1.7", - "jsdom": "^27.2.0", + "jsdom": "^27.3.0", "lint-staged": "^16.2.7", "msw": "^2.6.6", "prettier": "^3.7.3", diff --git a/frontend/src/api/organization-service/organization-service.api.ts b/frontend/src/api/organization-service/organization-service.api.ts index 1a65ed8436..fb038d7a18 100644 --- a/frontend/src/api/organization-service/organization-service.api.ts +++ b/frontend/src/api/organization-service/organization-service.api.ts @@ -97,7 +97,7 @@ export const organizationService = { emails: string[]; }) => { const { data } = await openHands.post( - `/api/organizations/${orgId}/invite/batch`, + `/api/organizations/${orgId}/members/invite`, { emails, }, diff --git a/frontend/src/settings-service/settings-service.api.ts b/frontend/src/api/settings-service/settings-service.api.ts similarity index 93% rename from frontend/src/settings-service/settings-service.api.ts rename to frontend/src/api/settings-service/settings-service.api.ts index 6d7309b8d1..f75e10c3e6 100644 --- a/frontend/src/settings-service/settings-service.api.ts +++ b/frontend/src/api/settings-service/settings-service.api.ts @@ -1,4 +1,4 @@ -import { openHands } from "../api/open-hands-axios"; +import { openHands } from "../open-hands-axios"; import { ApiSettings, PostApiSettings } from "./settings.types"; /** diff --git a/frontend/src/settings-service/settings.types.ts b/frontend/src/api/settings-service/settings.types.ts similarity index 100% rename from frontend/src/settings-service/settings.types.ts rename to frontend/src/api/settings-service/settings.types.ts diff --git a/frontend/src/components/features/chat/event-content-helpers/get-observation-content.ts b/frontend/src/components/features/chat/event-content-helpers/get-observation-content.ts index 56bf582263..435a686918 100644 --- a/frontend/src/components/features/chat/event-content-helpers/get-observation-content.ts +++ b/frontend/src/components/features/chat/event-content-helpers/get-observation-content.ts @@ -22,6 +22,13 @@ const getCommandObservationContent = ( if (content.length > MAX_CONTENT_LENGTH) { content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`; } + + const command = event.observation === "run" ? event.extras.command : null; + + if (command) { + return `Command:\n\`\`\`sh\n${command}\n\`\`\`\n\nOutput:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``; + } + return `Output:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``; }; diff --git a/frontend/src/components/features/chat/expandable-message.tsx b/frontend/src/components/features/chat/expandable-message.tsx index 12942498a2..f1f7fe6869 100644 --- a/frontend/src/components/features/chat/expandable-message.tsx +++ b/frontend/src/components/features/chat/expandable-message.tsx @@ -6,7 +6,6 @@ import { I18nKey } from "#/i18n/declaration"; import ArrowDown from "#/icons/angle-down-solid.svg?react"; import ArrowUp from "#/icons/angle-up-solid.svg?react"; import CheckCircle from "#/icons/check-circle-solid.svg?react"; -import XCircle from "#/icons/x-circle-solid.svg?react"; import { OpenHandsAction } from "#/types/core/actions"; import { OpenHandsObservation } from "#/types/core/observations"; import { cn } from "#/utils/utils"; @@ -169,19 +168,12 @@ export function ExpandableMessage({ )} - {type === "action" && success !== undefined && ( + {type === "action" && success && ( - {success ? ( - - ) : ( - - )} + )}
diff --git a/frontend/src/components/features/chat/success-indicator.tsx b/frontend/src/components/features/chat/success-indicator.tsx index 4e5ac4779a..12e16d67fe 100644 --- a/frontend/src/components/features/chat/success-indicator.tsx +++ b/frontend/src/components/features/chat/success-indicator.tsx @@ -1,6 +1,5 @@ import { FaClock } from "react-icons/fa"; import CheckCircle from "#/icons/check-circle-solid.svg?react"; -import XCircle from "#/icons/x-circle-solid.svg?react"; import { ObservationResultStatus } from "./event-content-helpers/get-observation-result"; interface SuccessIndicatorProps { @@ -17,13 +16,6 @@ export function SuccessIndicator({ status }: SuccessIndicatorProps) { /> )} - {status === "error" && ( - - )} - {status === "timeout" && ( ( - "conversation-selected-tab", + `conversation-selected-tab-${conversationId}`, "editor", ); const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] = - useLocalStorage("conversation-right-panel-shown", true); + useLocalStorage( + `conversation-right-panel-shown-${conversationId}`, + true, + ); const [persistedUnpinnedTabs] = useLocalStorage( - "conversation-unpinned-tabs", + `conversation-unpinned-tabs-${conversationId}`, [], ); diff --git a/frontend/src/components/features/settings/settings-navigation.tsx b/frontend/src/components/features/settings/settings-navigation.tsx index 91ef743302..2dc24a993d 100644 --- a/frontend/src/components/features/settings/settings-navigation.tsx +++ b/frontend/src/components/features/settings/settings-navigation.tsx @@ -88,7 +88,7 @@ export function SettingsNavigation({ .filter((navItem) => { const canViewOrgRoutes = !isUser && !!orgId && !isPersonalOrg; const routeVisibility: Record = { - "/settings/organization-members": canViewOrgRoutes, + "/settings/org-members": canViewOrgRoutes, "/settings/org": canViewOrgRoutes, "/settings/billing": !isTeamOrg, "/settings": !config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS, diff --git a/frontend/src/components/features/user/user-context-menu.tsx b/frontend/src/components/features/user/user-context-menu.tsx index 20bc872e07..2449131a43 100644 --- a/frontend/src/components/features/user/user-context-menu.tsx +++ b/frontend/src/components/features/user/user-context-menu.tsx @@ -71,7 +71,7 @@ export function UserContextMenu({ type, onClose }: UserContextMenuProps) { const navItems = (isOss ? OSS_NAV_ITEMS : SAAS_NAV_ITEMS).filter((item) => { const routeVisibility: Record = { // Org routes are handled separately in this menu (not shown in nav items) - "/settings/organization-members": false, + "/settings/org-members": false, "/settings/org": false, "/settings/billing": !isTeamOrg, // Hide LLM settings when the feature flag is enabled @@ -95,7 +95,7 @@ export function UserContextMenu({ type, onClose }: UserContextMenuProps) { }; const handleManageOrganizationMembersClick = () => { - navigate("/settings/organization-members"); + navigate("/settings/org-members"); onClose(); }; diff --git a/frontend/src/components/v1/chat/event-content-helpers/__tests__/get-observation-content.test.ts b/frontend/src/components/v1/chat/event-content-helpers/__tests__/get-observation-content.test.ts new file mode 100644 index 0000000000..d35dc97925 --- /dev/null +++ b/frontend/src/components/v1/chat/event-content-helpers/__tests__/get-observation-content.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { getObservationContent } from "../get-observation-content"; +import { ObservationEvent } from "#/types/v1/core"; +import { BrowserObservation } from "#/types/v1/core/base/observation"; + +describe("getObservationContent - BrowserObservation", () => { + it("should return output content when available", () => { + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "browser_navigate", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "BrowserObservation", + output: "Browser action completed", + error: null, + screenshot_data: "base64data", + }, + }; + + const result = getObservationContent(mockEvent); + + expect(result).toContain("**Output:**"); + expect(result).toContain("Browser action completed"); + }); + + it("should handle error cases properly", () => { + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "browser_navigate", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "BrowserObservation", + output: "", + error: "Browser action failed", + screenshot_data: null, + }, + }; + + const result = getObservationContent(mockEvent); + + expect(result).toContain("**Error:**"); + expect(result).toContain("Browser action failed"); + }); + + it("should provide default message when no output or error", () => { + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "browser_navigate", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "BrowserObservation", + output: "", + error: null, + screenshot_data: "base64data", + }, + }; + + const result = getObservationContent(mockEvent); + + expect(result).toBe("Browser action completed successfully."); + }); + + it("should return output when screenshot_data is null", () => { + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "browser_navigate", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "BrowserObservation", + output: "Page loaded successfully", + error: null, + screenshot_data: null, + }, + }; + + const result = getObservationContent(mockEvent); + + expect(result).toBe("**Output:**\nPage loaded successfully"); + }); +}); 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 da2558af42..bf443ea71c 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 @@ -98,14 +98,16 @@ const getBrowserObservationContent = ( .filter((c) => c.type === "text") .map((c) => c.text) .join("\n") - : ""; + : observation.output || ""; let contentDetails = ""; - if ("is_error" in observation && observation.is_error) { - contentDetails += `**Error:**\n${textContent}`; - } else { + if (observation.error) { + contentDetails += `**Error:**\n${observation.error}`; + } else if (textContent) { contentDetails += `**Output:**\n${textContent}`; + } else { + contentDetails += "Browser action completed successfully."; } if (contentDetails.length > MAX_CONTENT_LENGTH) { diff --git a/frontend/src/constants/settings-nav.tsx b/frontend/src/constants/settings-nav.tsx index 034f9fe127..b51deda3e2 100644 --- a/frontend/src/constants/settings-nav.tsx +++ b/frontend/src/constants/settings-nav.tsx @@ -55,7 +55,7 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [ text: "SETTINGS$NAV_MCP", }, { - to: "/settings/organization-members", + to: "/settings/org-members", text: "Organization Members", icon: , }, diff --git a/frontend/src/context/use-selected-organization.ts b/frontend/src/context/use-selected-organization.ts index d9023c504b..a56ed79379 100644 --- a/frontend/src/context/use-selected-organization.ts +++ b/frontend/src/context/use-selected-organization.ts @@ -1,46 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useRevalidator } from "react-router"; - -const key = "selected_organization" as const; +import { useSelectedOrgId } from "#/hooks/query/use-selected-organization-id"; +import { useUpdateSelectedOrganizationId } from "#/hooks/mutation/use-update-selected-organization-id"; export const useSelectedOrganizationId = () => { - const queryClient = useQueryClient(); - const revalidator = useRevalidator(); - - const { data: orgId } = useQuery({ - queryKey: [key], - initialData: null as string | null, - queryFn: () => { - const storedOrgId = queryClient.getQueryData([key]); - // Revalidate route clientLoader to ensure the latest orgId is used. - // This is useful for redirecting the user away from admin-only org pages. - revalidator.revalidate(); - return storedOrgId || null; // Return null if no org ID is set - }, - }); - - const updateState = useMutation({ - mutationFn: async (newValue: string | null) => { - queryClient.setQueryData([key], newValue); - return newValue; - }, - onMutate: async (newValue) => { - await queryClient.cancelQueries({ queryKey: [key] }); - - // Snapshot the previous value - const previousValue = queryClient.getQueryData([key]); - queryClient.setQueryData([key], newValue); - - return { previousValue }; - }, - onError: (_, __, context) => { - queryClient.setQueryData([key], context?.previousValue); - }, - // Always refetch after error or success - onSettled: () => { - queryClient.invalidateQueries({ queryKey: [key] }); - }, - }); + const { data: orgId } = useSelectedOrgId(); + const updateState = useUpdateSelectedOrganizationId(); return { orgId, diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index ae115c41a9..68c50f9499 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -14,6 +14,7 @@ import { useErrorMessageStore } from "#/stores/error-message-store"; import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store"; import { useCommandStore } from "#/state/command-store"; +import { useBrowserStore } from "#/stores/browser-store"; import { isV1Event, isAgentErrorEvent, @@ -27,6 +28,8 @@ import { isExecuteBashObservationEvent, isConversationErrorEvent, isPlanningFileEditorObservationEvent, + isBrowserObservationEvent, + isBrowserNavigateActionEvent, } from "#/types/v1/type-guards"; import { ConversationStateUpdateEventStats } from "#/types/v1/core/events/conversation-state-event"; import { handleActionEventCacheInvalidation } from "#/utils/cache-utils"; @@ -383,6 +386,22 @@ export function ConversationWebSocketProvider({ .join("\n"); appendOutput(textContent); } + + // Handle BrowserObservation events - update browser store with screenshot + if (isBrowserObservationEvent(event)) { + const { screenshot_data: screenshotData } = event.observation; + if (screenshotData) { + const screenshotSrc = screenshotData.startsWith("data:") + ? screenshotData + : `data:image/png;base64,${screenshotData}`; + useBrowserStore.getState().setScreenshotSrc(screenshotSrc); + } + } + + // Handle BrowserNavigateAction events - update browser store with URL + if (isBrowserNavigateActionEvent(event)) { + useBrowserStore.getState().setUrl(event.action.url); + } } } catch (error) { // eslint-disable-next-line no-console diff --git a/frontend/src/hooks/mutation/use-add-mcp-server.ts b/frontend/src/hooks/mutation/use-add-mcp-server.ts index 92725cbe58..25581cbdaf 100644 --- a/frontend/src/hooks/mutation/use-add-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-add-mcp-server.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSettings } from "#/hooks/query/use-settings"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings"; type MCPServerType = "sse" | "stdio" | "shttp"; @@ -57,6 +57,7 @@ export function useAddMcpServer() { const apiSettings = { mcp_config: newConfig, + v1_enabled: settings.V1_ENABLED, }; await SettingsService.saveSettings(apiSettings); diff --git a/frontend/src/hooks/mutation/use-delete-mcp-server.ts b/frontend/src/hooks/mutation/use-delete-mcp-server.ts index f060890ae8..42ee01601f 100644 --- a/frontend/src/hooks/mutation/use-delete-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-delete-mcp-server.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSettings } from "#/hooks/query/use-settings"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import { MCPConfig } from "#/types/settings"; export function useDeleteMcpServer() { @@ -25,6 +25,7 @@ export function useDeleteMcpServer() { const apiSettings = { mcp_config: newConfig, + v1_enabled: settings.V1_ENABLED, }; await SettingsService.saveSettings(apiSettings); 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..32edacedfd --- /dev/null +++ b/frontend/src/hooks/mutation/use-delete-organization.ts @@ -0,0 +1,22 @@ +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 { orgId, setOrgId } = useSelectedOrganizationId(); + + return useMutation({ + mutationFn: () => { + if (!orgId) throw new Error("Organization ID is required"); + return organizationService.deleteOrganization({ orgId }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["organizations"] }); + setOrgId(null); + navigate("/"); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index 099ab41ea4..168c1d11f1 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -1,9 +1,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { usePostHog } from "posthog-js/react"; import { DEFAULT_SETTINGS } from "#/services/settings"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import { PostSettings } from "#/types/settings"; -import { PostApiSettings } from "#/settings-service/settings.types"; +import { PostApiSettings } from "#/api/settings-service/settings.types"; import { useSettings } from "../query/use-settings"; const saveSettingsMutationFn = async (settings: Partial) => { diff --git a/frontend/src/hooks/mutation/use-update-mcp-server.ts b/frontend/src/hooks/mutation/use-update-mcp-server.ts index 83ef5dfcf3..7d7b7c9fd4 100644 --- a/frontend/src/hooks/mutation/use-update-mcp-server.ts +++ b/frontend/src/hooks/mutation/use-update-mcp-server.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSettings } from "#/hooks/query/use-settings"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings"; type MCPServerType = "sse" | "stdio" | "shttp"; @@ -59,6 +59,7 @@ export function useUpdateMcpServer() { const apiSettings = { mcp_config: newConfig, + v1_enabled: settings.V1_ENABLED, }; await SettingsService.saveSettings(apiSettings); 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..b5f0b0c6b7 --- /dev/null +++ b/frontend/src/hooks/mutation/use-update-organization.ts @@ -0,0 +1,18 @@ +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 { orgId } = useSelectedOrganizationId(); + + return useMutation({ + mutationFn: (name: string) => { + if (!orgId) throw new Error("Organization ID is required"); + return organizationService.updateOrganization({ orgId, name }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["organizations", orgId] }); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-update-selected-organization-id.ts b/frontend/src/hooks/mutation/use-update-selected-organization-id.ts new file mode 100644 index 0000000000..0c1a2c4a77 --- /dev/null +++ b/frontend/src/hooks/mutation/use-update-selected-organization-id.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { SELECTED_ORGANIZATION_QUERY_KEY } from "#/hooks/query/use-selected-organization-id"; + +export function useUpdateSelectedOrganizationId() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (newValue: string | null) => { + queryClient.setQueryData([SELECTED_ORGANIZATION_QUERY_KEY], newValue); + return newValue; + }, + onMutate: async (newValue) => { + await queryClient.cancelQueries({ + queryKey: [SELECTED_ORGANIZATION_QUERY_KEY], + }); + + // Snapshot the previous value + const previousValue = queryClient.getQueryData([ + SELECTED_ORGANIZATION_QUERY_KEY, + ]); + queryClient.setQueryData([SELECTED_ORGANIZATION_QUERY_KEY], newValue); + + return { previousValue }; + }, + onError: (_, __, context) => { + queryClient.setQueryData( + [SELECTED_ORGANIZATION_QUERY_KEY], + context?.previousValue, + ); + }, + // Always refetch after error or success + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: [SELECTED_ORGANIZATION_QUERY_KEY], + }); + }, + }); +} diff --git a/frontend/src/hooks/query/use-selected-organization-id.ts b/frontend/src/hooks/query/use-selected-organization-id.ts new file mode 100644 index 0000000000..ffa4841a02 --- /dev/null +++ b/frontend/src/hooks/query/use-selected-organization-id.ts @@ -0,0 +1,23 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRevalidator } from "react-router"; + +export const SELECTED_ORGANIZATION_QUERY_KEY = "selected_organization"; + +export function useSelectedOrgId() { + const queryClient = useQueryClient(); + const revalidator = useRevalidator(); + + return useQuery({ + queryKey: [SELECTED_ORGANIZATION_QUERY_KEY], + initialData: null as string | null, + queryFn: () => { + const storedOrgId = queryClient.getQueryData([ + SELECTED_ORGANIZATION_QUERY_KEY, + ]); + // Revalidate route clientLoader to ensure the latest orgId is used. + // This is useful for redirecting the user away from admin-only org pages. + revalidator.revalidate(); + return storedOrgId || null; // Return null if no org ID is set + }, + }); +} diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index c1769c8422..3f2e57c90d 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import SettingsService from "#/settings-service/settings-service.api"; +import SettingsService from "#/api/settings-service/settings-service.api"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page"; import { Settings } from "#/types/settings"; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 3f977f196c..a209d00e5c 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -697,6 +697,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$GITHUB_TOKEN_HELP_LINK = "GIT$GITHUB_TOKEN_HELP_LINK", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 85df9c6449..200edae194 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -11151,6 +11151,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トークンヘルプリンク", diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 31b90f359d..5b80bb7f0b 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -7,7 +7,7 @@ import { Provider } from "#/types/settings"; import { ApiSettings, PostApiSettings, -} from "#/settings-service/settings.types"; +} from "#/api/settings-service/settings.types"; import { FILE_SERVICE_HANDLERS } from "./file-service-handlers"; import { GitUser } from "#/types/git"; import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers"; diff --git a/frontend/src/mocks/mock-ws-helpers.ts b/frontend/src/mocks/mock-ws-helpers.ts index 512045766f..ae4e214943 100644 --- a/frontend/src/mocks/mock-ws-helpers.ts +++ b/frontend/src/mocks/mock-ws-helpers.ts @@ -184,3 +184,55 @@ export const createMockExecuteBashObservationEvent = ( }, action_id: "bash-action-123", }); + +/** + * Creates a mock BrowserObservation event for testing browser state handling + */ +export const createMockBrowserObservationEvent = ( + screenshotData: string | null = "base64-screenshot-data", + output: string = "Browser action completed", + error: string | null = null, +) => ({ + id: "browser-obs-123", + timestamp: new Date().toISOString(), + source: "environment", + tool_name: "browser_navigate", + tool_call_id: "browser-call-456", + observation: { + kind: "BrowserObservation", + output, + error, + screenshot_data: screenshotData, + }, + action_id: "browser-action-123", +}); + +/** + * Creates a mock BrowserNavigateAction event for testing browser URL extraction + */ +export const createMockBrowserNavigateActionEvent = ( + url: string = "https://example.com", +) => ({ + id: "browser-action-123", + timestamp: new Date().toISOString(), + source: "agent", + thought: [{ type: "text", text: "Navigating to URL" }], + thinking_blocks: [], + action: { + kind: "BrowserNavigateAction", + url, + new_tab: false, + }, + tool_name: "browser_navigate", + tool_call_id: "browser-call-456", + tool_call: { + id: "browser-call-456", + type: "function", + function: { + name: "browser_navigate", + arguments: JSON.stringify({ url, new_tab: false }), + }, + }, + llm_response_id: "llm-response-789", + security_risk: { level: "low" }, +}); diff --git a/frontend/src/mocks/org-handlers.ts b/frontend/src/mocks/org-handlers.ts index 75c61b8156..6e5fa89679 100644 --- a/frontend/src/mocks/org-handlers.ts +++ b/frontend/src/mocks/org-handlers.ts @@ -11,28 +11,68 @@ const MOCK_ME: Omit = { 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[] = [ - { - id: "1", - name: "Personal Workspace", - balance: 100, - is_personal: true, - }, - { - id: "2", - name: "Acme Corp", - balance: 1000, - }, - { - id: "3", - name: "Beta LLC", - balance: 500, - }, - { - id: "4", - name: "All Hands AI", - balance: 750, - }, + MOCK_PERSONAL_ORG, + MOCK_TEAM_ORG_ACME, + MOCK_TEAM_ORG_BETA, + MOCK_TEAM_ORG_ALLHANDS, ]; const INITIAL_MOCK_MEMBERS: Record = { @@ -332,7 +372,7 @@ export const ORG_HANDLERS = [ }), http.post( - "/api/organizations/:orgId/invite/batch", + "/api/organizations/:orgId/members/invite", async ({ request, params }) => { const { emails } = (await request.json()) as { emails: string[] }; const orgId = params.orgId?.toString(); diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index a2c524aba0..14a420ccc6 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -18,7 +18,7 @@ export default [ route("billing", "routes/billing.tsx"), route("secrets", "routes/secrets-settings.tsx"), route("api-keys", "routes/api-keys.tsx"), - route("organization-members", "routes/manage-organization-members.tsx"), + route("org-members", "routes/manage-organization-members.tsx"), route("org", "routes/manage-org.tsx"), ]), route("conversations/:conversationId", "routes/conversation.tsx"), diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx index 4206141cbe..e825bb3e0f 100644 --- a/frontend/src/routes/app-settings.tsx +++ b/frontend/src/routes/app-settings.tsx @@ -239,18 +239,20 @@ function AppSettingsScreen() { )} - + {!settings?.V1_ENABLED && ( + + )}

diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index 81c864fc25..056bf28c2c 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -112,11 +112,25 @@ function LlmSettingsScreen() { // Determine if we should hide the API key input and use OpenHands-managed key (when using OpenHands provider in SaaS mode) const currentModel = currentSelectedModel || settings?.LLM_MODEL; - const isOpenHandsProvider = - (view === "basic" && selectedProvider === "openhands") || - (view === "advanced" && currentModel?.startsWith("openhands/")); + const isSaasMode = config?.APP_MODE === "saas"; - const shouldUseOpenHandsKey = isOpenHandsProvider && isSaasMode; + + const isOpenHandsProvider = () => { + if (view === "basic") { + return selectedProvider === "openhands"; + } + + if (view === "advanced") { + if (dirtyInputs.model) { + return currentModel?.startsWith("openhands/"); + } + return settings?.LLM_MODEL?.startsWith("openhands/"); + } + + return false; + }; + + const shouldUseOpenHandsKey = isOpenHandsProvider() && isSaasMode; // Determine if we should hide the agent dropdown when V1 conversation API is enabled const isV1Enabled = settings?.V1_ENABLED; diff --git a/frontend/src/routes/manage-org.tsx b/frontend/src/routes/manage-org.tsx index 284e8b2df3..92f2d96129 100644 --- a/frontend/src/routes/manage-org.tsx +++ b/frontend/src/routes/manage-org.tsx @@ -1,6 +1,5 @@ import React from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { redirect, useNavigate } from "react-router"; +import { redirect } from "react-router"; import { useTranslation } from "react-i18next"; import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session"; import { useOrganization } from "#/hooks/query/use-organization"; @@ -8,7 +7,6 @@ import { useOrganizationPaymentInfo } from "#/hooks/query/use-organization-payme import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { cn } from "#/utils/utils"; import { organizationService } from "#/api/organization-service/organization-service.api"; -import { useSelectedOrganizationId } from "#/context/use-selected-organization"; import { SettingsInput } from "#/components/features/settings/settings-input"; import { BrandButton } from "#/components/features/settings/brand-button"; import { useMe } from "#/hooks/query/use-me"; @@ -19,6 +17,9 @@ import { } from "#/utils/query-client-getters"; import { queryClient } from "#/query-client-config"; import { I18nKey } from "#/i18n/declaration"; +import { amountIsValid } from "#/utils/amount-is-valid"; +import { useUpdateOrganization } from "#/hooks/mutation/use-update-organization"; +import { useDeleteOrganization } from "#/hooks/mutation/use-delete-organization"; function TempChip({ children, @@ -86,15 +87,7 @@ interface ChangeOrgNameModalProps { function ChangeOrgNameModal({ onClose }: ChangeOrgNameModalProps) { const { t } = useTranslation(); - const { orgId } = useSelectedOrganizationId(); - const qClient = useQueryClient(); - - const { mutate: updateOrganization } = useMutation({ - mutationFn: (name: string) => { - if (!orgId) throw new Error("Organization ID is required"); - return organizationService.updateOrganization({ orgId, name }); - }, - }); + const { mutate: updateOrganization } = useUpdateOrganization(); const formAction = (formData: FormData) => { const orgName = formData.get("org-name")?.toString(); @@ -102,7 +95,6 @@ function ChangeOrgNameModal({ onClose }: ChangeOrgNameModalProps) { if (orgName?.trim()) { updateOrganization(orgName, { onSuccess: () => { - qClient.invalidateQueries({ queryKey: ["organizations", orgId] }); onClose(); }, }); @@ -152,20 +144,7 @@ function DeleteOrgConfirmationModal({ onClose, }: DeleteOrgConfirmationModalProps) { const { t } = useTranslation(); - const qClient = useQueryClient(); - const navigate = useNavigate(); - const { orgId, setOrgId } = useSelectedOrganizationId(); - const { mutate: deleteOrganization } = useMutation({ - mutationFn: () => { - if (!orgId) throw new Error("Organization ID is required"); - return organizationService.deleteOrganization({ orgId }); - }, - onSuccess: () => { - qClient.invalidateQueries({ queryKey: ["organizations"] }); - setOrgId(null); - navigate("/"); - }, - }); + const { mutate: deleteOrganization } = useDeleteOrganization(); return (
@@ -191,21 +170,62 @@ 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); + // Clear error message when user starts typing again + setErrorMessage(null); + }; + return (

@@ -216,7 +236,18 @@ function AddCreditsModal({ onClose }: AddCreditsModalProps) { name="amount" type="number" className="text-lg bg-[#27272A] p-2" + placeholder={t(I18nKey.PAYMENT$SPECIFY_AMOUNT_USD)} + min={10} + max={25000} + step={1} + value={inputValue} + onChange={(e) => handleAmountInputChange(e.target.value)} /> + {errorMessage && ( +

+ {errorMessage} +

+ )}

@@ -289,7 +320,7 @@ function ManageOrg() {
- {organization?.balance} + {organization?.credits} {canAddCredits && ( setAddCreditsFormVisible(true)}> diff --git a/frontend/src/state/conversation-store.ts b/frontend/src/state/conversation-store.ts index cfbacc1f9a..a8edd16f6a 100644 --- a/frontend/src/state/conversation-store.ts +++ b/frontend/src/state/conversation-store.ts @@ -61,10 +61,48 @@ interface ConversationActions { type ConversationStore = ConversationState & ConversationActions; -// Helper function to get initial right panel state from localStorage +const getConversationIdFromLocation = (): string | null => { + if (typeof window === "undefined") { + return null; + } + + const match = window.location.pathname.match(/\/conversations\/([^/]+)/); + return match ? match[1] : null; +}; + +const parseStoredBoolean = (value: string | null): boolean | null => { + if (value === null) { + return null; + } + + try { + return JSON.parse(value); + } catch { + return null; + } +}; + const getInitialRightPanelState = (): boolean => { - const stored = localStorage.getItem("conversation-right-panel-shown"); - return stored !== null ? JSON.parse(stored) : true; + if (typeof window === "undefined") { + return true; + } + + const conversationId = getConversationIdFromLocation(); + const keysToCheck = conversationId + ? [`conversation-right-panel-shown-${conversationId}`] + : []; + + // Fallback to legacy global key for users who haven't switched tabs yet + keysToCheck.push("conversation-right-panel-shown"); + + for (const key of keysToCheck) { + const parsed = parseStoredBoolean(localStorage.getItem(key)); + if (parsed !== null) { + return parsed; + } + } + + return true; }; export const useConversationStore = create()( diff --git a/frontend/src/types/org.ts b/frontend/src/types/org.ts index 95f089996c..17dcef9e28 100644 --- a/frontend/src/types/org.ts +++ b/frontend/src/types/org.ts @@ -3,7 +3,33 @@ export type OrganizationUserRole = "user" | "admin" | "owner"; export interface Organization { id: string; name: string; - balance: number; + 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; } diff --git a/frontend/src/types/v1/type-guards.ts b/frontend/src/types/v1/type-guards.ts index 306661e854..ee831ea489 100644 --- a/frontend/src/types/v1/type-guards.ts +++ b/frontend/src/types/v1/type-guards.ts @@ -7,6 +7,8 @@ import { ExecuteBashObservation, PlanningFileEditorObservation, TerminalObservation, + BrowserObservation, + BrowserNavigateAction, } from "./core"; import { AgentErrorEvent } from "./core/events/observation-event"; import { MessageEvent } from "./core/events/message-event"; @@ -126,6 +128,22 @@ export const isPlanningFileEditorObservationEvent = ( isObservationEvent(event) && event.observation.kind === "PlanningFileEditorObservation"; +/** + * Type guard function to check if an observation event is a BrowserObservation + */ +export const isBrowserObservationEvent = ( + event: OpenHandsEvent, +): event is ObservationEvent => + isObservationEvent(event) && event.observation.kind === "BrowserObservation"; + +/** + * Type guard function to check if an action event is a BrowserNavigateAction + */ +export const isBrowserNavigateActionEvent = ( + event: OpenHandsEvent, +): event is ActionEvent => + isActionEvent(event) && event.action.kind === "BrowserNavigateAction"; + /** * Type guard function to check if an event is a system prompt event */ diff --git a/frontend/src/utils/query-client-getters.ts b/frontend/src/utils/query-client-getters.ts index d5cd910c4b..fb0b23528b 100644 --- a/frontend/src/utils/query-client-getters.ts +++ b/frontend/src/utils/query-client-getters.ts @@ -1,8 +1,9 @@ import { queryClient } from "#/query-client-config"; import { OrganizationMember } from "#/types/org"; +import { SELECTED_ORGANIZATION_QUERY_KEY } from "#/hooks/query/use-selected-organization-id"; export const getMeFromQueryClient = (orgId: string | undefined) => queryClient.getQueryData(["organizations", orgId, "me"]); export const getSelectedOrgFromQueryClient = () => - queryClient.getQueryData(["selected_organization"]); + queryClient.getQueryData([SELECTED_ORGANIZATION_QUERY_KEY]); diff --git a/openhands/README.md b/openhands/README.md index 5864a39b0e..93f06f26b3 100644 --- a/openhands/README.md +++ b/openhands/README.md @@ -2,8 +2,7 @@ This directory contains the core components of OpenHands. -This diagram provides an overview of the roles of each component and how they communicate and collaborate. -![OpenHands System Architecture Diagram (July 4, 2024)](../docs/static/img/system_architecture_overview.png) +For an overview of the system architecture, see the [architecture documentation](https://docs.openhands.dev/usage/architecture/backend) (v0 backend architecture). ## Classes 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 070640c907..2f04bf9a71 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 @@ -102,6 +102,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): sandbox_startup_poll_frequency: int httpx_client: httpx.AsyncClient web_url: str | None + openhands_provider_base_url: str | None access_token_hard_timeout: timedelta | None app_mode: str | None = None keycloak_auth_cookie: str | None = None @@ -526,13 +527,10 @@ class LiveStatusAppConversationService(AppConversationServiceBase): if not request.llm_model and parent_info.llm_model: request.llm_model = parent_info.llm_model - async def _setup_secrets_for_git_provider( - self, git_provider: ProviderType | None, user: UserInfo - ) -> dict: - """Set up secrets for git provider authentication. + async def _setup_secrets_for_git_providers(self, user: UserInfo) -> dict: + """Set up secrets for all git provider authentication. Args: - git_provider: The git provider type (GitHub, GitLab, etc.) user: User information containing authentication details Returns: @@ -540,35 +538,42 @@ class LiveStatusAppConversationService(AppConversationServiceBase): """ secrets = await self.user_context.get_secrets() - if not git_provider: + # Get all provider tokens from user authentication + provider_tokens = await self.user_context.get_provider_tokens() + if not provider_tokens: return secrets - secret_name = f'{git_provider.name}_TOKEN' + # Create secrets for each provider token + for provider_type, provider_token in provider_tokens.items(): + if not provider_token.token: + continue - if self.web_url: - # Create an access token for web-based authentication - access_token = self.jwt_service.create_jws_token( - payload={ - 'user_id': user.id, - 'provider_type': git_provider.value, - }, - expires_in=self.access_token_hard_timeout, - ) - headers = {'X-Access-Token': access_token} + secret_name = f'{provider_type.name}_TOKEN' - # Include keycloak_auth cookie in headers if app_mode is SaaS - if self.app_mode == 'saas' and self.keycloak_auth_cookie: - headers['Cookie'] = f'keycloak_auth={self.keycloak_auth_cookie}' + if self.web_url: + # Create an access token for web-based authentication + access_token = self.jwt_service.create_jws_token( + payload={ + 'user_id': user.id, + 'provider_type': provider_type.value, + }, + expires_in=self.access_token_hard_timeout, + ) + headers = {'X-Access-Token': access_token} - secrets[secret_name] = LookupSecret( - url=self.web_url + '/api/v1/webhooks/secrets', - headers=headers, - ) - else: - # Use static token for environments without web URL access - static_token = await self.user_context.get_latest_token(git_provider) - if static_token: - secrets[secret_name] = StaticSecret(value=static_token) + # Include keycloak_auth cookie in headers if app_mode is SaaS + if self.app_mode == 'saas' and self.keycloak_auth_cookie: + headers['Cookie'] = f'keycloak_auth={self.keycloak_auth_cookie}' + + secrets[secret_name] = LookupSecret( + url=self.web_url + '/api/v1/webhooks/secrets', + headers=headers, + ) + else: + # Use static token for environments without web URL access + static_token = await self.user_context.get_latest_token(provider_type) + if static_token: + secrets[secret_name] = StaticSecret(value=static_token) return secrets @@ -586,9 +591,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase): """ # Configure LLM model = llm_model or user.llm_model + base_url = user.llm_base_url + if model and model.startswith('openhands/'): + base_url = user.llm_base_url or self.openhands_provider_base_url llm = LLM( model=model, - base_url=user.llm_base_url, + base_url=base_url, api_key=user.llm_api_key, usage_id='agent', ) @@ -768,8 +776,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase): user = await self.user_context.get_user_info() workspace = LocalWorkspace(working_dir=working_dir) - # Set up secrets for git provider - secrets = await self._setup_secrets_for_git_provider(git_provider, user) + # Set up secrets for all git providers + secrets = await self._setup_secrets_for_git_providers(user) # Configure LLM and MCP llm, mcp_config = await self._configure_llm_and_mcp(user, llm_model) @@ -1078,6 +1086,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency, httpx_client=httpx_client, web_url=web_url, + openhands_provider_base_url=config.openhands_provider_base_url, access_token_hard_timeout=access_token_hard_timeout, app_mode=app_mode, keycloak_auth_cookie=keycloak_auth_cookie, diff --git a/openhands/app_server/config.py b/openhands/app_server/config.py index b44608a887..3c40806af0 100644 --- a/openhands/app_server/config.py +++ b/openhands/app_server/config.py @@ -74,6 +74,11 @@ def get_default_web_url() -> str | None: return f'https://{web_host}' +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 + + def _get_default_lifespan(): # Check legacy parameters for saas mode. If we are in SAAS mode do not apply # OSS alembic migrations @@ -88,6 +93,10 @@ class AppServerConfig(OpenHandsModel): default_factory=get_default_web_url, description='The URL where OpenHands is running (e.g., http://localhost:3000)', ) + openhands_provider_base_url: str | None = Field( + default_factory=get_openhands_provider_base_url, + description='Base URL for the OpenHands provider', + ) # Dependency Injection Injectors event: EventServiceInjector | None = None event_callback: EventCallbackServiceInjector | None = None diff --git a/openhands/app_server/event_callback/webhook_router.py b/openhands/app_server/event_callback/webhook_router.py index ac9812764d..28236b7325 100644 --- a/openhands/app_server/event_callback/webhook_router.py +++ b/openhands/app_server/event_callback/webhook_router.py @@ -60,16 +60,22 @@ _logger = logging.getLogger(__name__) async def valid_sandbox( - sandbox_id: str, user_context: UserContext = Depends(as_admin), session_api_key: str = Depends( APIKeyHeader(name='X-Session-API-Key', auto_error=False) ), sandbox_service: SandboxService = sandbox_service_dependency, ) -> SandboxInfo: - sandbox_info = await sandbox_service.get_sandbox(sandbox_id) - if sandbox_info is None or sandbox_info.session_api_key != session_api_key: - raise HTTPException(status.HTTP_401_UNAUTHORIZED) + if session_api_key is None: + 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' + ) return sandbox_info @@ -94,7 +100,7 @@ async def valid_conversation( return app_conversation_info -@router.post('/{sandbox_id}/conversations') +@router.post('/conversations') async def on_conversation_update( conversation_info: ConversationInfo, sandbox_info: SandboxInfo = Depends(valid_sandbox), @@ -125,7 +131,7 @@ async def on_conversation_update( return Success() -@router.post('/{sandbox_id}/events/{conversation_id}') +@router.post('/events/{conversation_id}') async def on_event( events: list[Event], conversation_id: UUID, diff --git a/openhands/app_server/sandbox/docker_sandbox_service.py b/openhands/app_server/sandbox/docker_sandbox_service.py index d7fe0b726d..a0aeddc0e6 100644 --- a/openhands/app_server/sandbox/docker_sandbox_service.py +++ b/openhands/app_server/sandbox/docker_sandbox_service.py @@ -260,6 +260,29 @@ class DockerSandboxService(SandboxService): except (NotFound, APIError): return None + async def get_sandbox_by_session_api_key( + self, session_api_key: str + ) -> SandboxInfo | None: + """Get a single sandbox by session API key.""" + try: + # Get all containers with our prefix + all_containers = self.docker_client.containers.list(all=True) + + for container in all_containers: + if container.name and container.name.startswith( + self.container_name_prefix + ): + # Check if this container has the matching session API key + env_vars = self._get_container_env_vars(container) + container_session_key = env_vars.get(SESSION_API_KEY_VARIABLE) + + if container_session_key == session_api_key: + return await self._container_to_checked_sandbox_info(container) + + return None + except (NotFound, APIError): + return None + async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo: """Start a new sandbox.""" # Enforce sandbox limits by cleaning up old sandboxes @@ -285,8 +308,7 @@ class DockerSandboxService(SandboxService): env_vars = sandbox_spec.initial_env.copy() env_vars[SESSION_API_KEY_VARIABLE] = session_api_key env_vars[WEBHOOK_CALLBACK_VARIABLE] = ( - f'http://host.docker.internal:{self.host_port}' - f'/api/v1/webhooks/{container_name}' + f'http://host.docker.internal:{self.host_port}/api/v1/webhooks' ) # Prepare port mappings and add port environment variables diff --git a/openhands/app_server/sandbox/process_sandbox_service.py b/openhands/app_server/sandbox/process_sandbox_service.py index 716c2e1b19..200bf62c44 100644 --- a/openhands/app_server/sandbox/process_sandbox_service.py +++ b/openhands/app_server/sandbox/process_sandbox_service.py @@ -275,6 +275,17 @@ class ProcessSandboxService(SandboxService): return await self._process_to_sandbox_info(sandbox_id, process_info) + async def get_sandbox_by_session_api_key( + self, session_api_key: str + ) -> SandboxInfo | None: + """Get a single sandbox by session API key.""" + # Search through all processes to find one with matching session_api_key + for sandbox_id, process_info in _processes.items(): + if process_info.session_api_key == session_api_key: + return await self._process_to_sandbox_info(sandbox_id, process_info) + + return None + async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo: """Start a new sandbox.""" # Get sandbox spec diff --git a/openhands/app_server/sandbox/remote_sandbox_service.py b/openhands/app_server/sandbox/remote_sandbox_service.py index dfa029462e..5ee42218dc 100644 --- a/openhands/app_server/sandbox/remote_sandbox_service.py +++ b/openhands/app_server/sandbox/remote_sandbox_service.py @@ -240,9 +240,7 @@ class RemoteSandboxService(SandboxService): # If a public facing url is defined, add a callback to the agent server environment. if self.web_url: - environment[WEBHOOK_CALLBACK_VARIABLE] = ( - f'{self.web_url}/api/v1/webhooks/{sandbox_id}' - ) + environment[WEBHOOK_CALLBACK_VARIABLE] = f'{self.web_url}/api/v1/webhooks' # We specify CORS settings only if there is a public facing url - otherwise # we are probably in local development and the only url in use is localhost environment[ALLOW_CORS_ORIGINS_VARIABLE] = self.web_url @@ -301,6 +299,27 @@ class RemoteSandboxService(SandboxService): return None return await self._to_sandbox_info(stored_sandbox) + async def get_sandbox_by_session_api_key( + self, session_api_key: str + ) -> Union[SandboxInfo, None]: + """Get a single sandbox by session API key.""" + # Get all stored sandboxes for the current user + stmt = await self._secure_select() + result = await self.db_session.execute(stmt) + stored_sandboxes = result.scalars().all() + + # Check each sandbox's runtime data for matching session_api_key + for stored_sandbox in stored_sandboxes: + try: + runtime = await self._get_runtime(stored_sandbox.id) + if runtime and runtime.get('session_api_key') == session_api_key: + return await self._to_sandbox_info(stored_sandbox, runtime) + except Exception: + # Continue checking other sandboxes if one fails + continue + + return None + async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo: """Start a new sandbox by creating a remote runtime.""" try: diff --git a/openhands/app_server/sandbox/sandbox_service.py b/openhands/app_server/sandbox/sandbox_service.py index 43393dfcf7..b1144a47cc 100644 --- a/openhands/app_server/sandbox/sandbox_service.py +++ b/openhands/app_server/sandbox/sandbox_service.py @@ -25,6 +25,12 @@ class SandboxService(ABC): async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None: """Get a single sandbox. Return None if the sandbox was not found.""" + @abstractmethod + async def get_sandbox_by_session_api_key( + self, session_api_key: str + ) -> SandboxInfo | None: + """Get a single sandbox by session API key. Return None if the sandbox was not found.""" + async def batch_get_sandboxes( self, sandbox_ids: list[str] ) -> list[SandboxInfo | None]: diff --git a/openhands/app_server/user/auth_user_context.py b/openhands/app_server/user/auth_user_context.py index e0b8fdd35f..8ea95036f4 100644 --- a/openhands/app_server/user/auth_user_context.py +++ b/openhands/app_server/user/auth_user_context.py @@ -9,7 +9,11 @@ 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, UserContextInjector from openhands.app_server.user.user_models import UserInfo -from openhands.integrations.provider import ProviderHandler, ProviderType +from openhands.integrations.provider import ( + PROVIDER_TOKEN_TYPE, + ProviderHandler, + ProviderType, +) from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret from openhands.server.user_auth.user_auth import UserAuth, get_user_auth @@ -44,6 +48,9 @@ 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_handler(self): provider_handler = self._provider_handler if not provider_handler: diff --git a/openhands/app_server/user/specifiy_user_context.py b/openhands/app_server/user/specifiy_user_context.py index d940061466..87e2d74da2 100644 --- a/openhands/app_server/user/specifiy_user_context.py +++ b/openhands/app_server/user/specifiy_user_context.py @@ -5,7 +5,7 @@ from fastapi import Request from openhands.app_server.errors import OpenHandsError from openhands.app_server.user.user_context import UserContext from openhands.app_server.user.user_models import UserInfo -from openhands.integrations.provider import ProviderType +from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType from openhands.sdk.conversation.secret_source import SecretSource @@ -24,6 +24,9 @@ class SpecifyUserContext(UserContext): async def get_authenticated_git_url(self, repository: str) -> str: raise NotImplementedError() + async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None: + raise NotImplementedError() + async def get_latest_token(self, provider_type: ProviderType) -> str | None: raise NotImplementedError() diff --git a/openhands/app_server/user/user_context.py b/openhands/app_server/user/user_context.py index 0971b71570..02c0ba8aaf 100644 --- a/openhands/app_server/user/user_context.py +++ b/openhands/app_server/user/user_context.py @@ -4,7 +4,7 @@ from openhands.app_server.services.injector import Injector from openhands.app_server.user.user_models import ( UserInfo, ) -from openhands.integrations.provider import ProviderType +from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType from openhands.sdk.conversation.secret_source import SecretSource from openhands.sdk.utils.models import DiscriminatedUnionMixin @@ -26,6 +26,10 @@ class UserContext(ABC): async def get_authenticated_git_url(self, repository: str) -> str: """Get the provider tokens for the user""" + @abstractmethod + async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None: + """Get the latest tokens for all provider types""" + @abstractmethod async def get_latest_token(self, provider_type: ProviderType) -> str | None: """Get the latest token for the provider type given""" diff --git a/openhands/utils/llm.py b/openhands/utils/llm.py index 9eeb7c5393..1686babd96 100644 --- a/openhands/utils/llm.py +++ b/openhands/utils/llm.py @@ -90,4 +90,4 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]: ] model_list = clarifai_models + model_list - return list(sorted(set(model_list))) + return sorted(set(model_list)) 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 00d80abf64..1dabdfa88a 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 @@ -28,6 +28,8 @@ class TestLiveStatusAppConversationService: """Set up test fixtures.""" # Create mock dependencies self.mock_user_context = Mock(spec=UserContext) + self.mock_user_auth = Mock() + self.mock_user_context.user_auth = self.mock_user_auth self.mock_jwt_service = Mock() self.mock_sandbox_service = Mock() self.mock_sandbox_spec_service = Mock() @@ -50,6 +52,7 @@ class TestLiveStatusAppConversationService: 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', keycloak_auth_cookie=None, @@ -64,6 +67,7 @@ class TestLiveStatusAppConversationService: 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 + self.mock_user.llm_base_url = 'https://api.openai.com/v1' # Mock sandbox self.mock_sandbox = Mock(spec=SandboxInfo) @@ -71,67 +75,83 @@ class TestLiveStatusAppConversationService: self.mock_sandbox.status = SandboxStatus.RUNNING @pytest.mark.asyncio - async def test_setup_secrets_for_git_provider_no_provider(self): - """Test _setup_secrets_for_git_provider with no git provider.""" + async def test_setup_secrets_for_git_providers_no_provider_tokens(self): + """Test _setup_secrets_for_git_providers with no provider tokens.""" # Arrange base_secrets = {'existing': 'secret'} self.mock_user_context.get_secrets.return_value = base_secrets + self.mock_user_context.get_provider_tokens = AsyncMock(return_value=None) # Act - result = await self.service._setup_secrets_for_git_provider( - None, self.mock_user - ) + result = await self.service._setup_secrets_for_git_providers(self.mock_user) # Assert assert result == base_secrets self.mock_user_context.get_secrets.assert_called_once() + self.mock_user_context.get_provider_tokens.assert_called_once() @pytest.mark.asyncio - async def test_setup_secrets_for_git_provider_with_web_url(self): - """Test _setup_secrets_for_git_provider with web URL (creates access token).""" + async def test_setup_secrets_for_git_providers_with_web_url(self): + """Test _setup_secrets_for_git_providers with web URL (creates access token).""" # Arrange + from pydantic import SecretStr + + from openhands.integrations.provider import ProviderToken + base_secrets = {} self.mock_user_context.get_secrets.return_value = base_secrets self.mock_jwt_service.create_jws_token.return_value = 'test_access_token' - git_provider = ProviderType.GITHUB + + # Mock provider tokens + provider_tokens = { + ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')), + ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')), + } + self.mock_user_context.get_provider_tokens = AsyncMock( + return_value=provider_tokens + ) # Act - result = await self.service._setup_secrets_for_git_provider( - git_provider, self.mock_user - ) + result = await self.service._setup_secrets_for_git_providers(self.mock_user) # Assert assert 'GITHUB_TOKEN' in result + assert 'GITLAB_TOKEN' in result assert isinstance(result['GITHUB_TOKEN'], LookupSecret) + assert isinstance(result['GITLAB_TOKEN'], LookupSecret) assert ( result['GITHUB_TOKEN'].url == 'https://test.example.com/api/v1/webhooks/secrets' ) assert result['GITHUB_TOKEN'].headers['X-Access-Token'] == 'test_access_token' - self.mock_jwt_service.create_jws_token.assert_called_once_with( - payload={ - 'user_id': self.mock_user.id, - 'provider_type': git_provider.value, - }, - expires_in=None, - ) + # Should be called twice, once for each provider + assert self.mock_jwt_service.create_jws_token.call_count == 2 @pytest.mark.asyncio - async def test_setup_secrets_for_git_provider_with_saas_mode(self): - """Test _setup_secrets_for_git_provider with SaaS mode (includes keycloak cookie).""" + async def test_setup_secrets_for_git_providers_with_saas_mode(self): + """Test _setup_secrets_for_git_providers with SaaS mode (includes keycloak cookie).""" # Arrange + from pydantic import SecretStr + + from openhands.integrations.provider import ProviderToken + self.service.app_mode = 'saas' self.service.keycloak_auth_cookie = 'test_cookie' base_secrets = {} self.mock_user_context.get_secrets.return_value = base_secrets self.mock_jwt_service.create_jws_token.return_value = 'test_access_token' - git_provider = ProviderType.GITLAB + + # Mock provider tokens + provider_tokens = { + ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')), + } + self.mock_user_context.get_provider_tokens = AsyncMock( + return_value=provider_tokens + ) # Act - result = await self.service._setup_secrets_for_git_provider( - git_provider, self.mock_user - ) + result = await self.service._setup_secrets_for_git_providers(self.mock_user) # Assert assert 'GITLAB_TOKEN' in result @@ -141,40 +161,60 @@ class TestLiveStatusAppConversationService: assert lookup_secret.headers['Cookie'] == 'keycloak_auth=test_cookie' @pytest.mark.asyncio - async def test_setup_secrets_for_git_provider_without_web_url(self): - """Test _setup_secrets_for_git_provider without web URL (uses static token).""" + async def test_setup_secrets_for_git_providers_without_web_url(self): + """Test _setup_secrets_for_git_providers without web URL (uses static token).""" # Arrange + from pydantic import SecretStr + + from openhands.integrations.provider import ProviderToken + self.service.web_url = None base_secrets = {} self.mock_user_context.get_secrets.return_value = base_secrets self.mock_user_context.get_latest_token.return_value = 'static_token_value' - git_provider = ProviderType.GITHUB + + # Mock provider tokens + provider_tokens = { + ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')), + } + self.mock_user_context.get_provider_tokens = AsyncMock( + return_value=provider_tokens + ) # Act - result = await self.service._setup_secrets_for_git_provider( - git_provider, self.mock_user - ) + result = await self.service._setup_secrets_for_git_providers(self.mock_user) # Assert assert 'GITHUB_TOKEN' in result assert isinstance(result['GITHUB_TOKEN'], StaticSecret) assert result['GITHUB_TOKEN'].value.get_secret_value() == 'static_token_value' - self.mock_user_context.get_latest_token.assert_called_once_with(git_provider) + self.mock_user_context.get_latest_token.assert_called_once_with( + ProviderType.GITHUB + ) @pytest.mark.asyncio - async def test_setup_secrets_for_git_provider_no_static_token(self): - """Test _setup_secrets_for_git_provider when no static token is available.""" + async def test_setup_secrets_for_git_providers_no_static_token(self): + """Test _setup_secrets_for_git_providers when no static token is available.""" # Arrange + from pydantic import SecretStr + + from openhands.integrations.provider import ProviderToken + self.service.web_url = None base_secrets = {} self.mock_user_context.get_secrets.return_value = base_secrets self.mock_user_context.get_latest_token.return_value = None - git_provider = ProviderType.GITHUB + + # Mock provider tokens + provider_tokens = { + ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')), + } + self.mock_user_context.get_provider_tokens = AsyncMock( + return_value=provider_tokens + ) # Act - result = await self.service._setup_secrets_for_git_provider( - git_provider, self.mock_user - ) + result = await self.service._setup_secrets_for_git_providers(self.mock_user) # Assert assert 'GITHUB_TOKEN' not in result @@ -203,6 +243,70 @@ class TestLiveStatusAppConversationService: assert mcp_config['default']['url'] == 'https://test.example.com/mcp/mcp' assert mcp_config['default']['headers']['X-Session-API-Key'] == 'mcp_api_key' + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_openhands_model_prefers_user_base_url(self): + """openhands/* model uses user.llm_base_url when provided.""" + # Arrange + self.mock_user.llm_model = 'openhands/special' + self.mock_user.llm_base_url = 'https://user-llm.example.com' + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, _ = await self.service._configure_llm_and_mcp( + self.mock_user, self.mock_user.llm_model + ) + + # Assert + assert llm.base_url == 'https://user-llm.example.com' + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_openhands_model_uses_provider_default(self): + """openhands/* model falls back to configured provider base URL.""" + # Arrange + self.mock_user.llm_model = 'openhands/default' + self.mock_user.llm_base_url = None + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, _ = await self.service._configure_llm_and_mcp( + self.mock_user, self.mock_user.llm_model + ) + + # Assert + assert llm.base_url == 'https://provider.example.com' + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_openhands_model_no_base_urls(self): + """openhands/* model sets base_url to None when no sources available.""" + # Arrange + self.mock_user.llm_model = 'openhands/default' + self.mock_user.llm_base_url = None + self.service.openhands_provider_base_url = None + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, _ = await self.service._configure_llm_and_mcp( + self.mock_user, self.mock_user.llm_model + ) + + # Assert + assert llm.base_url is None + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_non_openhands_model_ignores_provider(self): + """Non-openhands model ignores provider base URL and uses user base URL.""" + # Arrange + self.mock_user.llm_model = 'gpt-4' + self.mock_user.llm_base_url = 'https://user-llm.example.com' + self.service.openhands_provider_base_url = 'https://provider.example.com' + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, _ = await self.service._configure_llm_and_mcp(self.mock_user, None) + + # Assert + assert llm.base_url == 'https://user-llm.example.com' + @pytest.mark.asyncio async def test_configure_llm_and_mcp_with_user_default_model(self): """Test _configure_llm_and_mcp using user's default model.""" @@ -677,7 +781,7 @@ class TestLiveStatusAppConversationService: mock_agent = Mock(spec=Agent) mock_final_request = Mock(spec=StartConversationRequest) - self.service._setup_secrets_for_git_provider = AsyncMock( + self.service._setup_secrets_for_git_providers = AsyncMock( return_value=mock_secrets ) self.service._configure_llm_and_mcp = AsyncMock( @@ -705,8 +809,8 @@ class TestLiveStatusAppConversationService: # Assert assert result == mock_final_request - self.service._setup_secrets_for_git_provider.assert_called_once_with( - ProviderType.GITHUB, self.mock_user + self.service._setup_secrets_for_git_providers.assert_called_once_with( + self.mock_user ) self.service._configure_llm_and_mcp.assert_called_once_with( self.mock_user, 'gpt-4' diff --git a/tests/unit/app_server/test_remote_sandbox_service.py b/tests/unit/app_server/test_remote_sandbox_service.py index 567ecad2e3..5802e46ecb 100644 --- a/tests/unit/app_server/test_remote_sandbox_service.py +++ b/tests/unit/app_server/test_remote_sandbox_service.py @@ -291,9 +291,7 @@ class TestEnvironmentInitialization: ) # Verify - expected_webhook_url = ( - 'https://web.example.com/api/v1/webhooks/test-sandbox-123' - ) + expected_webhook_url = 'https://web.example.com/api/v1/webhooks' assert environment['EXISTING_VAR'] == 'existing_value' assert environment[WEBHOOK_CALLBACK_VARIABLE] == expected_webhook_url assert environment[ALLOW_CORS_ORIGINS_VARIABLE] == 'https://web.example.com' diff --git a/tests/unit/app_server/test_sandbox_service.py b/tests/unit/app_server/test_sandbox_service.py index 9a65131821..f3eea1d2ea 100644 --- a/tests/unit/app_server/test_sandbox_service.py +++ b/tests/unit/app_server/test_sandbox_service.py @@ -27,6 +27,7 @@ class MockSandboxService(SandboxService): def __init__(self): self.search_sandboxes_mock = AsyncMock() self.get_sandbox_mock = AsyncMock() + self.get_sandbox_by_session_api_key_mock = AsyncMock() self.start_sandbox_mock = AsyncMock() self.resume_sandbox_mock = AsyncMock() self.pause_sandbox_mock = AsyncMock() @@ -40,6 +41,11 @@ class MockSandboxService(SandboxService): async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None: return await self.get_sandbox_mock(sandbox_id) + async def get_sandbox_by_session_api_key( + self, session_api_key: str + ) -> SandboxInfo | None: + return await self.get_sandbox_by_session_api_key_mock(session_api_key) + async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo: return await self.start_sandbox_mock(sandbox_spec_id) diff --git a/tests/unit/experiments/test_experiment_manager.py b/tests/unit/experiments/test_experiment_manager.py index a21943ace2..85faa078f5 100644 --- a/tests/unit/experiments/test_experiment_manager.py +++ b/tests/unit/experiments/test_experiment_manager.py @@ -188,6 +188,7 @@ class TestExperimentManagerIntegration: sandbox_startup_poll_frequency=1, httpx_client=httpx_client, web_url=None, + openhands_provider_base_url=None, access_token_hard_timeout=None, ) @@ -203,7 +204,7 @@ class TestExperimentManagerIntegration: with ( patch.object( service, - '_setup_secrets_for_git_provider', + '_setup_secrets_for_git_providers', return_value={}, ), patch.object( diff --git a/tests/unit/server/data_models/test_conversation.py b/tests/unit/server/data_models/test_conversation.py index cf12e83361..79ff91fa7f 100644 --- a/tests/unit/server/data_models/test_conversation.py +++ b/tests/unit/server/data_models/test_conversation.py @@ -2166,6 +2166,7 @@ async def test_delete_v1_conversation_with_sub_conversations(): sandbox_startup_poll_frequency=2, httpx_client=mock_httpx_client, web_url=None, + openhands_provider_base_url=None, access_token_hard_timeout=None, ) @@ -2287,6 +2288,7 @@ async def test_delete_v1_conversation_with_no_sub_conversations(): sandbox_startup_poll_frequency=2, httpx_client=mock_httpx_client, web_url=None, + openhands_provider_base_url=None, access_token_hard_timeout=None, ) @@ -2438,6 +2440,7 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error(): sandbox_startup_poll_frequency=2, httpx_client=mock_httpx_client, web_url=None, + openhands_provider_base_url=None, access_token_hard_timeout=None, )