From d08886f30e41121c3c36bd6b4aee2ca32e61aa33 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Thu, 21 Nov 2024 19:18:49 +0100 Subject: [PATCH 01/16] Fix non-function calls messages (#5026) Co-authored-by: Xingyao Wang --- openhands/core/message.py | 48 +++++++++++++++++++----------- openhands/llm/fn_call_converter.py | 12 ++++---- openhands/llm/llm.py | 21 ++++++------- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/openhands/core/message.py b/openhands/core/message.py index a707ea3881ea..a5b67917eaee 100644 --- a/openhands/core/message.py +++ b/openhands/core/message.py @@ -56,6 +56,7 @@ class Message(BaseModel): cache_enabled: bool = False vision_enabled: bool = False # function calling + function_calling_enabled: bool = False # - tool calls (from LLM) tool_calls: list[ChatCompletionMessageToolCall] | None = None # - tool execution result (to LLM) @@ -72,22 +73,22 @@ def serialize_model(self) -> dict: # - into a single string: for providers that don't support list of content items (e.g. no vision, no tool calls) # - into a list of content items: the new APIs of providers with vision/prompt caching/tool calls # NOTE: remove this when litellm or providers support the new API - if ( - self.cache_enabled - or self.vision_enabled - or self.tool_call_id is not None - or self.tool_calls is not None - ): + if self.cache_enabled or self.vision_enabled or self.function_calling_enabled: return self._list_serializer() + # some providers, like HF and Groq/llama, don't support a list here, but a single string return self._string_serializer() - def _string_serializer(self): + def _string_serializer(self) -> dict: + # convert content to a single string content = '\n'.join( item.text for item in self.content if isinstance(item, TextContent) ) - return {'content': content, 'role': self.role} + message_dict: dict = {'content': content, 'role': self.role} + + # add tool call keys if we have a tool call or response + return self._add_tool_call_keys(message_dict) - def _list_serializer(self): + def _list_serializer(self) -> dict: content: list[dict] = [] role_tool_with_prompt_caching = False for item in self.content: @@ -102,24 +103,37 @@ def _list_serializer(self): elif isinstance(item, ImageContent) and self.vision_enabled: content.extend(d) - ret: dict = {'content': content, 'role': self.role} + message_dict: dict = {'content': content, 'role': self.role} + # pop content if it's empty if not content or ( len(content) == 1 and content[0]['type'] == 'text' and content[0]['text'] == '' ): - ret.pop('content') + message_dict.pop('content') if role_tool_with_prompt_caching: - ret['cache_control'] = {'type': 'ephemeral'} + message_dict['cache_control'] = {'type': 'ephemeral'} + + # add tool call keys if we have a tool call or response + return self._add_tool_call_keys(message_dict) + def _add_tool_call_keys(self, message_dict: dict) -> dict: + """Add tool call keys if we have a tool call or response. + + NOTE: this is necessary for both native and non-native tool calling""" + + # an assistant message calling a tool + if self.tool_calls is not None: + message_dict['tool_calls'] = self.tool_calls + + # an observation message with tool response if self.tool_call_id is not None: assert ( self.name is not None ), 'name is required when tool_call_id is not None' - ret['tool_call_id'] = self.tool_call_id - ret['name'] = self.name - if self.tool_calls: - ret['tool_calls'] = self.tool_calls - return ret + message_dict['tool_call_id'] = self.tool_call_id + message_dict['name'] = self.name + + return message_dict diff --git a/openhands/llm/fn_call_converter.py b/openhands/llm/fn_call_converter.py index 5ddbcb305064..b33c7b43503b 100644 --- a/openhands/llm/fn_call_converter.py +++ b/openhands/llm/fn_call_converter.py @@ -320,9 +320,8 @@ def convert_fncall_messages_to_non_fncall_messages( converted_messages = [] first_user_message_encountered = False for message in messages: - role, content = message['role'], message['content'] - if content is None: - content = '' + role = message['role'] + content = message.get('content', '') # 1. SYSTEM MESSAGES # append system prompt suffix to content @@ -339,6 +338,7 @@ def convert_fncall_messages_to_non_fncall_messages( f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' ) converted_messages.append({'role': 'system', 'content': content}) + # 2. USER MESSAGES (no change) elif role == 'user': # Add in-context learning example for the first user message @@ -447,10 +447,12 @@ def convert_fncall_messages_to_non_fncall_messages( f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' ) converted_messages.append({'role': 'assistant', 'content': content}) + # 4. TOOL MESSAGES (tool outputs) elif role == 'tool': - # Convert tool result as assistant message - prefix = f'EXECUTION RESULT of [{message["name"]}]:\n' + # Convert tool result as user message + tool_name = message.get('name', 'function') + prefix = f'EXECUTION RESULT of [{tool_name}]:\n' # and omit "tool_call_id" AND "name" if isinstance(content, str): content = prefix + content diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py index 0f9f6376c79c..2191818f8216 100644 --- a/openhands/llm/llm.py +++ b/openhands/llm/llm.py @@ -122,6 +122,9 @@ def __init__( drop_params=self.config.drop_params, ) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.init_model_info() if self.vision_is_active(): logger.debug('LLM: model has vision enabled') if self.is_caching_prompt_active(): @@ -143,16 +146,6 @@ def __init__( drop_params=self.config.drop_params, ) - with warnings.catch_warnings(): - warnings.simplefilter('ignore') - self.init_model_info() - if self.vision_is_active(): - logger.debug('LLM: model has vision enabled') - if self.is_caching_prompt_active(): - logger.debug('LLM: caching prompt enabled') - if self.is_function_calling_active(): - logger.debug('LLM: model supports function calling') - self._completion_unwrapped = self._completion @self.retry_decorator( @@ -342,6 +335,13 @@ def init_model_info(self): pass logger.debug(f'Model info: {self.model_info}') + if self.config.model.startswith('huggingface'): + # HF doesn't support the OpenAI default value for top_p (1) + logger.debug( + f'Setting top_p to 0.9 for Hugging Face model: {self.config.model}' + ) + self.config.top_p = 0.9 if self.config.top_p == 1 else self.config.top_p + # Set the max tokens in an LM-specific way if not set if self.config.max_input_tokens is None: if ( @@ -566,6 +566,7 @@ def format_messages_for_llm(self, messages: Message | list[Message]) -> list[dic for message in messages: message.cache_enabled = self.is_caching_prompt_active() message.vision_enabled = self.vision_is_active() + message.function_calling_enabled = self.is_function_calling_active() # let pydantic handle the serialization return [message.model_dump() for message in messages] From ea6809b283dec09e6716a3e33c46fecefe766767 Mon Sep 17 00:00:00 2001 From: diwu-sf Date: Thu, 21 Nov 2024 11:17:58 -0800 Subject: [PATCH 02/16] =?UTF-8?q?rename=20github=20to=20github=5Futils=20t?= =?UTF-8?q?o=20avoid=20import=20circular=20dependency=20pro=E2=80=A6=20(#5?= =?UTF-8?q?180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openhands/server/{github.py => github_utils.py} | 0 openhands/server/listen.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename openhands/server/{github.py => github_utils.py} (100%) diff --git a/openhands/server/github.py b/openhands/server/github_utils.py similarity index 100% rename from openhands/server/github.py rename to openhands/server/github_utils.py diff --git a/openhands/server/listen.py b/openhands/server/listen.py index c8b258b46ead..51d2696720a5 100644 --- a/openhands/server/listen.py +++ b/openhands/server/listen.py @@ -13,7 +13,7 @@ from openhands.security.options import SecurityAnalyzers from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback -from openhands.server.github import ( +from openhands.server.github_utils import ( GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, UserVerifier, From 39dad706ca43c4f3e8c8e90134e5c20f9f65d550 Mon Sep 17 00:00:00 2001 From: mamoodi Date: Thu, 21 Nov 2024 14:42:33 -0500 Subject: [PATCH 03/16] Release 0.14.2 (#5182) --- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5abe9cc6a58..2b718e2c146f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "openhands-frontend", - "version": "0.14.1", + "version": "0.14.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhands-frontend", - "version": "0.14.1", + "version": "0.14.2", "dependencies": { "@monaco-editor/react": "^4.6.0", "@nextui-org/react": "^2.4.8", diff --git a/frontend/package.json b/frontend/package.json index 1757adbe8ac3..98a1408857de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "openhands-frontend", - "version": "0.14.1", + "version": "0.14.2", "private": true, "type": "module", "engines": { diff --git a/pyproject.toml b/pyproject.toml index 53648ae7d8e8..fc1807f72fc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "openhands-ai" -version = "0.14.1" +version = "0.14.2" description = "OpenHands: Code Less, Make More" authors = ["OpenHands"] license = "MIT" From 68d1e76ccda11eb8e900a532795d1f35afd8cabe Mon Sep 17 00:00:00 2001 From: niliy01 Date: Fri, 22 Nov 2024 08:55:26 +0800 Subject: [PATCH 04/16] fix: remove repeated completion assignment in llm.py (#5167) --- openhands/llm/llm.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py index 2191818f8216..58f41ca46244 100644 --- a/openhands/llm/llm.py +++ b/openhands/llm/llm.py @@ -108,20 +108,8 @@ def __init__( ) os.makedirs(self.config.log_completions_folder, exist_ok=True) - self._completion = partial( - litellm_completion, - model=self.config.model, - api_key=self.config.api_key, - base_url=self.config.base_url, - api_version=self.config.api_version, - custom_llm_provider=self.config.custom_llm_provider, - max_tokens=self.config.max_output_tokens, - timeout=self.config.timeout, - temperature=self.config.temperature, - top_p=self.config.top_p, - drop_params=self.config.drop_params, - ) - + # call init_model_info to initialize config.max_output_tokens + # which is used in partial function with warnings.catch_warnings(): warnings.simplefilter('ignore') self.init_model_info() From 83add629917de56f4f7a238df36977b6a88bbcfd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 10:50:36 +0400 Subject: [PATCH 05/16] Bump the eslint group across 1 directory with 2 updates (#5200) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 113 ++++++------------------------------- frontend/package.json | 4 +- 2 files changed, 18 insertions(+), 99 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2b718e2c146f..5f72fe6d0b96 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -69,9 +69,9 @@ "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", - "eslint-plugin-jsx-a11y": "^6.9.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.2.1", - "eslint-plugin-react": "^7.35.0", + "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^4.6.2", "husky": "^9.1.6", "jsdom": "^25.0.1", @@ -8161,38 +8161,6 @@ "node": ">=6" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -8593,26 +8561,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-iterator-helpers": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.1.0.tgz", @@ -9062,12 +9010,12 @@ } }, "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.0.tgz", - "integrity": "sha512-ySOHvXX8eSN6zz8Bywacm7CvGNhUtdjvqfQDVe6020TUK34Cywkw7m0KsCCk1Qtm9G1FayfTN1/7mMYnYO2Bhg==", + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, "dependencies": { - "aria-query": "~5.1.3", + "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", @@ -9075,14 +9023,13 @@ "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", - "es-iterator-helpers": "^1.0.19", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.0" + "string.prototype.includes": "^2.0.1" }, "engines": { "node": ">=4.0" @@ -9092,12 +9039,12 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, - "dependencies": { - "deep-equal": "^2.0.5" + "engines": { + "node": ">= 0.4" } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { @@ -9153,9 +9100,9 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.37.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.1.tgz", - "integrity": "sha512-xwTnwDqzbDRA8uJ7BMxPs/EXRB3i8ZfnOIp8BsxEQkT0nHPp+WWceqGgo6rKb9ctNi8GJLDT4Go5HAWELa/WMg==", + "version": "7.37.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.2.tgz", + "integrity": "sha512-EsTAnj9fLVr/GZleBLFbj/sSuXeWmp1eXIN60ceYnZveqEaUCyW4X+Vh4WTdUhCkW4xutXYqTXCUSyqD4rB75w==", "dev": true, "dependencies": { "array-includes": "^3.1.8", @@ -9163,7 +9110,7 @@ "array.prototype.flatmap": "^1.3.2", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.0.19", + "es-iterator-helpers": "^1.1.0", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", @@ -18934,22 +18881,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -22698,18 +22629,6 @@ "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", "dev": true }, - "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "dev": true, - "dependencies": { - "internal-slot": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 98a1408857de..8e134ec5f0e1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -95,9 +95,9 @@ "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", - "eslint-plugin-jsx-a11y": "^6.9.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.2.1", - "eslint-plugin-react": "^7.35.0", + "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^4.6.2", "husky": "^9.1.6", "jsdom": "^25.0.1", From 135a62ca9cae5a66b679a48872fb0888e1a1e448 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Fri, 22 Nov 2024 09:28:38 -0500 Subject: [PATCH 06/16] [Resolver]: Removing redundant checks (#5196) Co-authored-by: Graham Neubig --- .../resolver/examples/openhands-resolver.yml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/openhands/resolver/examples/openhands-resolver.yml b/openhands/resolver/examples/openhands-resolver.yml index 2e2f42be0bac..13571b7703e1 100644 --- a/openhands/resolver/examples/openhands-resolver.yml +++ b/openhands/resolver/examples/openhands-resolver.yml @@ -19,21 +19,6 @@ permissions: jobs: call-openhands-resolver: - if: | - github.event.label.name == 'fix-me' || - - ( - ((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') && - (startsWith(github.event.comment.body, inputs.macro || '@openhands-agent') || startsWith(github.event.comment.body, inputs.macro || vars.OPENHANDS_MACRO)) && - (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER') - ) || - - (github.event_name == 'pull_request_review' && - (startsWith(github.event.review.body, inputs.macro || '@openhands-agent') || startsWith(github.event.review.body, inputs.macro || vars.OPENHANDS_MACRO)) && - (github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER') - ) - ) - uses: All-Hands-AI/OpenHands/.github/workflows/openhands-resolver.yml@main with: macro: ${{ vars.OPENHANDS_MACRO || '@openhands-agent' }} From 24d5facec5c9a0e0f9b37dc5259d7104cce32e22 Mon Sep 17 00:00:00 2001 From: Raymond Xu Date: Fri, 22 Nov 2024 08:43:45 -0800 Subject: [PATCH 07/16] Show the link to the All Hands product roadmap (#5192) Co-authored-by: Graham Neubig --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 97de3104472c..77633dbbbf72 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ See more about the community in [COMMUNITY.md](./COMMUNITY.md) or find details o ## 📈 Progress +See the monthly OpenHands roadmap [here](https://github.com/orgs/All-Hands-AI/projects/1) (updated at the maintainer's meeting at the end of each month). +

Star History Chart From 36e3dc5c19c323c45de88aa18906dc560fc99d4b Mon Sep 17 00:00:00 2001 From: mamoodi Date: Fri, 22 Nov 2024 13:24:33 -0500 Subject: [PATCH 08/16] Add eval workflow that triggers remote eval job (#5108) --- .github/workflows/run-eval.yml | 53 ++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/run-eval.yml diff --git a/.github/workflows/run-eval.yml b/.github/workflows/run-eval.yml new file mode 100644 index 000000000000..df79872aec26 --- /dev/null +++ b/.github/workflows/run-eval.yml @@ -0,0 +1,53 @@ +# Run evaluation on a PR +name: Run Eval + +# Runs when a PR is labeled with one of the "run-eval-" labels +on: + pull_request: + types: [labeled] + +jobs: + trigger-job: + name: Trigger remote eval job + if: ${{ github.event.label.name == 'run-eval-xs' || github.event.label.name == 'run-eval-s' || github.event.label.name == 'run-eval-m' }} + runs-on: ubuntu-latest + + steps: + - name: Checkout PR branch + uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + + - name: Trigger remote job + run: | + REPO_URL="https://github.com/${{ github.repository }}" + PR_BRANCH="${{ github.head_ref }}" + echo "Repository URL: $REPO_URL" + echo "PR Branch: $PR_BRANCH" + + if [[ "${{ github.event.label.name }}" == "run-eval-xs" ]]; then + EVAL_INSTANCES="1" + elif [[ "${{ github.event.label.name }}" == "run-eval-s" ]]; then + EVAL_INSTANCES="5" + elif [[ "${{ github.event.label.name }}" == "run-eval-m" ]]; then + EVAL_INSTANCES="30" + fi + + curl -X POST \ + -H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \ + -H "Accept: application/vnd.github+json" \ + -d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${REPO_URL}\", \"github-branch\": \"${PR_BRANCH}\", \"pr-number\": \"${{ github.event.pull_request.number }}\", \"eval-instances\": \"${EVAL_INSTANCES}\"}}" \ + https://api.github.com/repos/All-Hands-AI/evaluation/actions/workflows/create-branch.yml/dispatches + + # Send Slack message + PR_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}" + slack_text="PR $PR_URL has triggered evaluation on $EVAL_INSTANCES instances..." + curl -X POST -H 'Content-type: application/json' --data '{"text":"'"$slack_text"'"}' \ + https://hooks.slack.com/services/${{ secrets.SLACK_TOKEN }} + + - name: Comment on PR + uses: KeisukeYamashita/create-comment@v1 + with: + unique: false + comment: | + Running evaluation on the PR. Once eval is done, the results will be posted. From bb8b4a0b18c3ff32ddbcaec5ebb7b87fac860e90 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Fri, 22 Nov 2024 12:28:32 -0600 Subject: [PATCH 09/16] feat(runtime): add system resource metrics to /server_info endpoint (#5207) Co-authored-by: openhands --- openhands/runtime/action_execution_server.py | 8 ++- openhands/runtime/utils/system_stats.py | 62 ++++++++++++++++++++ tests/runtime/utils/test_system_stats.py | 60 +++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 openhands/runtime/utils/system_stats.py create mode 100644 tests/runtime/utils/test_system_stats.py diff --git a/openhands/runtime/action_execution_server.py b/openhands/runtime/action_execution_server.py index 1251aa346838..e8043133d9b5 100644 --- a/openhands/runtime/action_execution_server.py +++ b/openhands/runtime/action_execution_server.py @@ -52,6 +52,7 @@ from openhands.runtime.utils.files import insert_lines, read_lines from openhands.runtime.utils.runtime_init import init_user_and_working_directory from openhands.runtime.utils.system import check_port_available +from openhands.runtime.utils.system_stats import get_system_stats from openhands.utils.async_utils import call_sync_from_async, wait_all @@ -420,7 +421,12 @@ async def get_server_info(): current_time = time.time() uptime = current_time - client.start_time idle_time = current_time - client.last_execution_time - return {'uptime': uptime, 'idle_time': idle_time} + + return { + 'uptime': uptime, + 'idle_time': idle_time, + 'resources': get_system_stats(), + } @app.post('/execute_action') async def execute_action(action_request: ActionRequest): diff --git a/openhands/runtime/utils/system_stats.py b/openhands/runtime/utils/system_stats.py new file mode 100644 index 000000000000..d0068c248793 --- /dev/null +++ b/openhands/runtime/utils/system_stats.py @@ -0,0 +1,62 @@ +"""Utilities for getting system resource statistics.""" + +import time + +import psutil + + +def get_system_stats() -> dict: + """Get current system resource statistics. + + Returns: + dict: A dictionary containing: + - cpu_percent: CPU usage percentage for the current process + - memory: Memory usage stats (rss, vms, percent) + - disk: Disk usage stats (total, used, free, percent) + - io: I/O statistics (read/write bytes) + """ + process = psutil.Process() + # Get initial CPU percentage (this will return 0.0) + process.cpu_percent() + # Wait a bit and get the actual CPU percentage + time.sleep(0.1) + + with process.oneshot(): + cpu_percent = process.cpu_percent() + memory_info = process.memory_info() + memory_percent = process.memory_percent() + + disk_usage = psutil.disk_usage('/') + + # Get I/O stats directly from /proc/[pid]/io to avoid psutil's field name assumptions + try: + with open(f'/proc/{process.pid}/io', 'rb') as f: + io_stats = {} + for line in f: + if line: + try: + name, value = line.strip().split(b': ') + io_stats[name.decode('ascii')] = int(value) + except (ValueError, UnicodeDecodeError): + continue + except (FileNotFoundError, PermissionError): + io_stats = {'read_bytes': 0, 'write_bytes': 0} + + return { + 'cpu_percent': cpu_percent, + 'memory': { + 'rss': memory_info.rss, + 'vms': memory_info.vms, + 'percent': memory_percent, + }, + 'disk': { + 'total': disk_usage.total, + 'used': disk_usage.used, + 'free': disk_usage.free, + 'percent': disk_usage.percent, + }, + 'io': { + 'read_bytes': io_stats.get('read_bytes', 0), + 'write_bytes': io_stats.get('write_bytes', 0), + }, + } diff --git a/tests/runtime/utils/test_system_stats.py b/tests/runtime/utils/test_system_stats.py new file mode 100644 index 000000000000..afb6c00c2942 --- /dev/null +++ b/tests/runtime/utils/test_system_stats.py @@ -0,0 +1,60 @@ +"""Tests for system stats utilities.""" + +import psutil + +from openhands.runtime.utils.system_stats import get_system_stats + + +def test_get_system_stats(): + """Test that get_system_stats returns valid system statistics.""" + stats = get_system_stats() + + # Test structure + assert isinstance(stats, dict) + assert set(stats.keys()) == {'cpu_percent', 'memory', 'disk', 'io'} + + # Test CPU stats + assert isinstance(stats['cpu_percent'], float) + assert 0 <= stats['cpu_percent'] <= 100 * psutil.cpu_count() + + # Test memory stats + assert isinstance(stats['memory'], dict) + assert set(stats['memory'].keys()) == {'rss', 'vms', 'percent'} + assert isinstance(stats['memory']['rss'], int) + assert isinstance(stats['memory']['vms'], int) + assert isinstance(stats['memory']['percent'], float) + assert stats['memory']['rss'] > 0 + assert stats['memory']['vms'] > 0 + assert 0 <= stats['memory']['percent'] <= 100 + + # Test disk stats + assert isinstance(stats['disk'], dict) + assert set(stats['disk'].keys()) == {'total', 'used', 'free', 'percent'} + assert isinstance(stats['disk']['total'], int) + assert isinstance(stats['disk']['used'], int) + assert isinstance(stats['disk']['free'], int) + assert isinstance(stats['disk']['percent'], float) + assert stats['disk']['total'] > 0 + assert stats['disk']['used'] >= 0 + assert stats['disk']['free'] >= 0 + assert 0 <= stats['disk']['percent'] <= 100 + # Verify that used + free is less than or equal to total + # (might not be exactly equal due to filesystem overhead) + assert stats['disk']['used'] + stats['disk']['free'] <= stats['disk']['total'] + + # Test I/O stats + assert isinstance(stats['io'], dict) + assert set(stats['io'].keys()) == {'read_bytes', 'write_bytes'} + assert isinstance(stats['io']['read_bytes'], int) + assert isinstance(stats['io']['write_bytes'], int) + assert stats['io']['read_bytes'] >= 0 + assert stats['io']['write_bytes'] >= 0 + + +def test_get_system_stats_stability(): + """Test that get_system_stats can be called multiple times without errors.""" + # Call multiple times to ensure stability + for _ in range(3): + stats = get_system_stats() + assert isinstance(stats, dict) + assert stats['cpu_percent'] >= 0 From becb17f0c86ca4c2454bfff59a14b0e780860589 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Fri, 22 Nov 2024 23:38:27 +0400 Subject: [PATCH 10/16] feat(frontend): Utilize TanStack Query (#5096) --- frontend/.eslintrc | 3 +- frontend/__tests__/clear-session.test.ts | 40 --- .../components/feedback-form.test.tsx | 14 +- .../file-explorer/FileExplorer.test.tsx | 13 +- .../components/user-actions.test.tsx | 12 +- frontend/__tests__/routes/_oh.test.tsx | 162 ++++++++++-- frontend/__tests__/utils/cache.test.ts | 53 ---- .../utils/extract-next-page-from-link.test.ts | 13 + .../utils/handle-capture-consent.test.ts | 44 ++++ frontend/package-lock.json | 139 ++++++++++ frontend/package.json | 2 + frontend/playwright.config.ts | 6 +- frontend/src/api/github.ts | 76 +----- frontend/src/api/open-hands.ts | 196 +++++++++----- .../analytics-consent-form-modal.tsx | 29 +- frontend/src/components/chat-interface.tsx | 7 +- frontend/src/components/controls.tsx | 26 +- frontend/src/components/event-handler.tsx | 86 +++--- frontend/src/components/feedback-form.tsx | 27 +- .../components/file-explorer/FileExplorer.tsx | 108 ++++---- .../src/components/file-explorer/TreeNode.tsx | 66 ++--- .../src/components/form/settings-form.tsx | 88 ++++--- .../github-repositories-suggestion-box.tsx | 11 +- .../modals/AccountSettingsModal.tsx | 52 ++-- .../modals/ConnectToGitHubByTokenModal.tsx | 54 ---- .../modals/connect-to-github-modal.tsx | 28 +- .../project-menu/ProjectMenuCard.tsx | 2 +- .../project-menu/project-menu-details.tsx | 4 +- frontend/src/components/user-actions.tsx | 9 +- frontend/src/context/auth-context.tsx | 82 ++++++ frontend/src/context/user-prefs-context.tsx | 55 ++++ frontend/src/entry.client.tsx | 31 ++- frontend/src/hooks/mutation/use-save-file.ts | 21 ++ .../src/hooks/mutation/use-submit-feedback.ts | 21 ++ .../src/hooks/mutation/use-upload-files.ts | 16 ++ .../src/hooks/query/use-ai-config-options.ts | 14 + frontend/src/hooks/query/use-config.ts | 8 + frontend/src/hooks/query/use-github-user.ts | 40 +++ frontend/src/hooks/query/use-is-authed.ts | 19 ++ .../src/hooks/query/use-latest-repo-commit.ts | 28 ++ frontend/src/hooks/query/use-list-file.ts | 17 ++ frontend/src/hooks/query/use-list-files.ts | 24 ++ .../src/hooks/query/use-user-repositories.ts | 63 +++++ frontend/src/hooks/use-end-session.ts | 31 +++ frontend/src/hooks/use-github-auth-url.ts | 20 ++ frontend/src/routes/_oh._index/route.tsx | 113 +++----- frontend/src/routes/_oh._index/task-form.tsx | 31 ++- .../_oh.app._index/code-editor-component.tsx | 67 ++--- frontend/src/routes/_oh.app._index/route.tsx | 43 +-- frontend/src/routes/_oh.app.tsx | 76 ++---- frontend/src/routes/_oh.tsx | 249 ++++++------------ frontend/src/routes/end-session.ts | 7 - frontend/src/routes/login.ts | 9 - frontend/src/routes/logout.ts | 13 - frontend/src/routes/oauth.github.callback.tsx | 42 +-- frontend/src/routes/set-consent.ts | 9 - frontend/src/routes/settings.ts | 108 -------- frontend/src/services/auth.ts | 31 +-- frontend/src/utils/cache.ts | 61 ----- frontend/src/utils/clear-session.ts | 21 -- .../src/utils/extract-next-page-from-link.ts | 11 + frontend/src/utils/handle-capture-consent.ts | 15 ++ frontend/src/utils/settings-utils.ts | 95 +++++++ frontend/src/utils/user-is-authenticated.ts | 20 -- frontend/test-utils.tsx | 18 +- frontend/tests/redirect.spec.ts | 12 + 66 files changed, 1617 insertions(+), 1294 deletions(-) delete mode 100644 frontend/__tests__/clear-session.test.ts delete mode 100644 frontend/__tests__/utils/cache.test.ts create mode 100644 frontend/__tests__/utils/extract-next-page-from-link.test.ts create mode 100644 frontend/__tests__/utils/handle-capture-consent.test.ts delete mode 100644 frontend/src/components/modals/ConnectToGitHubByTokenModal.tsx create mode 100644 frontend/src/context/auth-context.tsx create mode 100644 frontend/src/context/user-prefs-context.tsx create mode 100644 frontend/src/hooks/mutation/use-save-file.ts create mode 100644 frontend/src/hooks/mutation/use-submit-feedback.ts create mode 100644 frontend/src/hooks/mutation/use-upload-files.ts create mode 100644 frontend/src/hooks/query/use-ai-config-options.ts create mode 100644 frontend/src/hooks/query/use-config.ts create mode 100644 frontend/src/hooks/query/use-github-user.ts create mode 100644 frontend/src/hooks/query/use-is-authed.ts create mode 100644 frontend/src/hooks/query/use-latest-repo-commit.ts create mode 100644 frontend/src/hooks/query/use-list-file.ts create mode 100644 frontend/src/hooks/query/use-list-files.ts create mode 100644 frontend/src/hooks/query/use-user-repositories.ts create mode 100644 frontend/src/hooks/use-end-session.ts create mode 100644 frontend/src/hooks/use-github-auth-url.ts delete mode 100644 frontend/src/routes/end-session.ts delete mode 100644 frontend/src/routes/login.ts delete mode 100644 frontend/src/routes/logout.ts delete mode 100644 frontend/src/routes/set-consent.ts delete mode 100644 frontend/src/routes/settings.ts delete mode 100644 frontend/src/utils/cache.ts delete mode 100644 frontend/src/utils/clear-session.ts create mode 100644 frontend/src/utils/extract-next-page-from-link.ts create mode 100644 frontend/src/utils/handle-capture-consent.ts create mode 100644 frontend/src/utils/settings-utils.ts delete mode 100644 frontend/src/utils/user-is-authenticated.ts diff --git a/frontend/.eslintrc b/frontend/.eslintrc index d5cb543bd728..29896d083c35 100644 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -10,7 +10,8 @@ "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", - "plugin:react-hooks/recommended" + "plugin:react-hooks/recommended", + "plugin:@tanstack/query/recommended" ], "plugins": [ "prettier" diff --git a/frontend/__tests__/clear-session.test.ts b/frontend/__tests__/clear-session.test.ts deleted file mode 100644 index 4a172608497f..000000000000 --- a/frontend/__tests__/clear-session.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { clearSession } from "../src/utils/clear-session"; -import store from "../src/store"; -import { initialState as browserInitialState } from "../src/state/browserSlice"; - -describe("clearSession", () => { - beforeEach(() => { - // Mock localStorage - const localStorageMock = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), - }; - vi.stubGlobal("localStorage", localStorageMock); - - // Set initial browser state to non-default values - store.dispatch({ - type: "browser/setUrl", - payload: "https://example.com", - }); - store.dispatch({ - type: "browser/setScreenshotSrc", - payload: "base64screenshot", - }); - }); - - it("should clear localStorage and reset browser state", () => { - clearSession(); - - // Verify localStorage items were removed - expect(localStorage.removeItem).toHaveBeenCalledWith("token"); - expect(localStorage.removeItem).toHaveBeenCalledWith("repo"); - - // Verify browser state was reset - const state = store.getState(); - expect(state.browser.url).toBe(browserInitialState.url); - expect(state.browser.screenshotSrc).toBe(browserInitialState.screenshotSrc); - }); -}); diff --git a/frontend/__tests__/components/feedback-form.test.tsx b/frontend/__tests__/components/feedback-form.test.tsx index 28684401e2cb..f686c45bf9eb 100644 --- a/frontend/__tests__/components/feedback-form.test.tsx +++ b/frontend/__tests__/components/feedback-form.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "test-utils"; import { FeedbackForm } from "#/components/feedback-form"; describe("FeedbackForm", () => { @@ -12,7 +13,9 @@ describe("FeedbackForm", () => { }); it("should render correctly", () => { - render(); + renderWithProviders( + , + ); screen.getByLabelText("Email"); screen.getByLabelText("Private"); @@ -23,7 +26,9 @@ describe("FeedbackForm", () => { }); it("should switch between private and public permissions", async () => { - render(); + renderWithProviders( + , + ); const privateRadio = screen.getByLabelText("Private"); const publicRadio = screen.getByLabelText("Public"); @@ -40,10 +45,11 @@ describe("FeedbackForm", () => { }); it("should call onClose when the close button is clicked", async () => { - render(); + renderWithProviders( + , + ); await user.click(screen.getByRole("button", { name: "Cancel" })); expect(onCloseMock).toHaveBeenCalled(); }); - }); diff --git a/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx b/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx index a1c0717783e9..357dd61e1bd9 100644 --- a/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx +++ b/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx @@ -16,16 +16,13 @@ vi.mock("../../services/fileService", async () => ({ })); const renderFileExplorerWithRunningAgentState = () => - renderWithProviders( - {}} />, - { - preloadedState: { - agent: { - curAgentState: AgentState.RUNNING, - }, + renderWithProviders( {}} />, { + preloadedState: { + agent: { + curAgentState: AgentState.RUNNING, }, }, - ); + }); describe.skip("FileExplorer", () => { afterEach(() => { diff --git a/frontend/__tests__/components/user-actions.test.tsx b/frontend/__tests__/components/user-actions.test.tsx index 6186562ab9f1..b9bb65d0f3c9 100644 --- a/frontend/__tests__/components/user-actions.test.tsx +++ b/frontend/__tests__/components/user-actions.test.tsx @@ -1,7 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, test, vi, afterEach } from "vitest"; import userEvent from "@testing-library/user-event"; -import * as Remix from "@remix-run/react"; import { UserActions } from "#/components/user-actions"; describe("UserActions", () => { @@ -9,14 +8,9 @@ describe("UserActions", () => { const onClickAccountSettingsMock = vi.fn(); const onLogoutMock = vi.fn(); - const useFetcherSpy = vi.spyOn(Remix, "useFetcher"); - // @ts-expect-error - Only returning the relevant properties for the test - useFetcherSpy.mockReturnValue({ state: "idle" }); - afterEach(() => { onClickAccountSettingsMock.mockClear(); onLogoutMock.mockClear(); - useFetcherSpy.mockClear(); }); it("should render", () => { @@ -111,10 +105,8 @@ describe("UserActions", () => { expect(onLogoutMock).not.toHaveBeenCalled(); }); - it("should display the loading spinner", () => { - // @ts-expect-error - Only returning the relevant properties for the test - useFetcherSpy.mockReturnValue({ state: "loading" }); - + // FIXME: Spinner now provided through useQuery + it.skip("should display the loading spinner", () => { render( { - describe("brand logo", () => { - it.todo("should not do anything if the user is in the main screen"); - it.todo( - "should be clickable and redirect to the main screen if the user is not in the main screen", + const RemixStub = createRemixStub([{ Component: MainApp, path: "/" }]); + + const { userIsAuthenticatedMock, settingsAreUpToDateMock } = vi.hoisted( + () => ({ + userIsAuthenticatedMock: vi.fn(), + settingsAreUpToDateMock: vi.fn(), + }), + ); + + beforeAll(() => { + vi.mock("#/utils/user-is-authenticated", () => ({ + userIsAuthenticated: userIsAuthenticatedMock.mockReturnValue(true), + })); + + vi.mock("#/services/settings", async (importOriginal) => ({ + ...(await importOriginal()), + settingsAreUpToDate: settingsAreUpToDateMock, + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it("should render", async () => { + renderWithProviders(); + await screen.findByTestId("root-layout"); + }); + + it("should render the AI config modal if the user is authed", async () => { + // Our mock return value is true by default + renderWithProviders(); + await screen.findByTestId("ai-config-modal"); + }); + + it("should render the AI config modal if settings are not up-to-date", async () => { + settingsAreUpToDateMock.mockReturnValue(false); + renderWithProviders(); + + await screen.findByTestId("ai-config-modal"); + }); + + it("should not render the AI config modal if the settings are up-to-date", async () => { + settingsAreUpToDateMock.mockReturnValue(true); + renderWithProviders(); + + await waitFor(() => { + expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument(); + }); + }); + + it("should capture the user's consent", async () => { + const user = userEvent.setup(); + const handleCaptureConsentSpy = vi.spyOn( + CaptureConsent, + "handleCaptureConsent", ); + + renderWithProviders(); + + // The user has not consented to tracking + const consentForm = await screen.findByTestId("user-capture-consent-form"); + expect(handleCaptureConsentSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem("analytics-consent")).toBeNull(); + + const submitButton = within(consentForm).getByRole("button", { + name: /confirm preferences/i, + }); + await user.click(submitButton); + + // The user has now consented to tracking + expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true); + expect(localStorage.getItem("analytics-consent")).toBe("true"); + expect( + screen.queryByTestId("user-capture-consent-form"), + ).not.toBeInTheDocument(); }); - describe("user menu", () => { - it.todo("should open the user menu when clicked"); + it("should not render the user consent form if the user has already made a decision", async () => { + localStorage.setItem("analytics-consent", "true"); + renderWithProviders(); - describe("logged out", () => { - it.todo("should display a placeholder"); - test.todo("the logout option in the user menu should be disabled"); + await waitFor(() => { + expect( + screen.queryByTestId("user-capture-consent-form"), + ).not.toBeInTheDocument(); }); + }); + + it("should render a new project button if a token is set", async () => { + localStorage.setItem("token", "test-token"); + const { rerender } = renderWithProviders(); - describe("logged in", () => { - it.todo("should display the user's avatar"); - it.todo("should log the user out when the logout option is clicked"); + await screen.findByTestId("new-project-button"); + + localStorage.removeItem("token"); + rerender(); + + await waitFor(() => { + expect( + screen.queryByTestId("new-project-button"), + ).not.toBeInTheDocument(); }); }); - describe("config", () => { - it.todo("should open the config modal when clicked"); - it.todo( - "should not save the config and close the config modal when the close button is clicked", - ); - it.todo( - "should save the config when the save button is clicked and close the modal", - ); - it.todo("should warn the user about saving the config when in /app"); + // TODO: Move to e2e tests + it.skip("should update the i18n language when the language settings change", async () => { + const changeLanguageSpy = vi.spyOn(i18n, "changeLanguage"); + const { rerender } = renderWithProviders(); + + // The default language is English + expect(changeLanguageSpy).toHaveBeenCalledWith("en"); + + localStorage.setItem("LANGUAGE", "es"); + + rerender(); + expect(changeLanguageSpy).toHaveBeenCalledWith("es"); + + rerender(); + // The language has not changed, so the spy should not have been called again + expect(changeLanguageSpy).toHaveBeenCalledTimes(2); + }); + + // FIXME: logoutCleanup has been replaced with a hook + it.skip("should call logoutCleanup after a logout", async () => { + const user = userEvent.setup(); + localStorage.setItem("ghToken", "test-token"); + + // const logoutCleanupSpy = vi.spyOn(LogoutCleanup, "logoutCleanup"); + renderWithProviders(); + + const userActions = await screen.findByTestId("user-actions"); + const userAvatar = within(userActions).getByTestId("user-avatar"); + await user.click(userAvatar); + + const logout = within(userActions).getByRole("button", { name: /logout/i }); + await user.click(logout); + + // expect(logoutCleanupSpy).toHaveBeenCalled(); + expect(localStorage.getItem("ghToken")).toBeNull(); }); }); diff --git a/frontend/__tests__/utils/cache.test.ts b/frontend/__tests__/utils/cache.test.ts deleted file mode 100644 index 6b3762c38a65..000000000000 --- a/frontend/__tests__/utils/cache.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { afterEach } from "node:test"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { cache } from "#/utils/cache"; - -describe("Cache", () => { - const testKey = "key"; - const testData = { message: "Hello, world!" }; - const testTTL = 1000; // 1 second - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("gets data from memory if not expired", () => { - cache.set(testKey, testData, testTTL); - - expect(cache.get(testKey)).toEqual(testData); - }); - - it("should expire after 5 minutes by default", () => { - cache.set(testKey, testData); - expect(cache.get(testKey)).not.toBeNull(); - - vi.advanceTimersByTime(5 * 60 * 1000 + 1); - - expect(cache.get(testKey)).toBeNull(); - }); - - it("returns null if cached data is expired", () => { - cache.set(testKey, testData, testTTL); - - vi.advanceTimersByTime(testTTL + 1); - expect(cache.get(testKey)).toBeNull(); - }); - - it("deletes data from memory", () => { - cache.set(testKey, testData, testTTL); - cache.delete(testKey); - expect(cache.get(testKey)).toBeNull(); - }); - - it("clears all data with the app prefix from memory", () => { - cache.set(testKey, testData, testTTL); - cache.set("anotherKey", { data: "More data" }, testTTL); - cache.clearAll(); - expect(cache.get(testKey)).toBeNull(); - expect(cache.get("anotherKey")).toBeNull(); - }); -}); diff --git a/frontend/__tests__/utils/extract-next-page-from-link.test.ts b/frontend/__tests__/utils/extract-next-page-from-link.test.ts new file mode 100644 index 000000000000..a7541f95a0ad --- /dev/null +++ b/frontend/__tests__/utils/extract-next-page-from-link.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "vitest"; +import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link"; + +test("extractNextPageFromLink", () => { + const link = `; rel="prev", ; rel="next", ; rel="last", ; rel="first"`; + expect(extractNextPageFromLink(link)).toBe(4); + + const noNextLink = `; rel="prev", ; rel="first"`; + expect(extractNextPageFromLink(noNextLink)).toBeNull(); + + const extra = `; rel="next", ; rel="last"`; + expect(extractNextPageFromLink(extra)).toBe(2); +}); diff --git a/frontend/__tests__/utils/handle-capture-consent.test.ts b/frontend/__tests__/utils/handle-capture-consent.test.ts new file mode 100644 index 000000000000..3b337424a7ae --- /dev/null +++ b/frontend/__tests__/utils/handle-capture-consent.test.ts @@ -0,0 +1,44 @@ +import posthog from "posthog-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { handleCaptureConsent } from "#/utils/handle-capture-consent"; + +describe("handleCaptureConsent", () => { + const optInSpy = vi.spyOn(posthog, "opt_in_capturing"); + const optOutSpy = vi.spyOn(posthog, "opt_out_capturing"); + const hasOptedInSpy = vi.spyOn(posthog, "has_opted_in_capturing"); + const hasOptedOutSpy = vi.spyOn(posthog, "has_opted_out_capturing"); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should opt out of of capturing", () => { + handleCaptureConsent(false); + + expect(optOutSpy).toHaveBeenCalled(); + expect(optInSpy).not.toHaveBeenCalled(); + }); + + it("should opt in to capturing if the user consents", () => { + handleCaptureConsent(true); + + expect(optInSpy).toHaveBeenCalled(); + expect(optOutSpy).not.toHaveBeenCalled(); + }); + + it("should not opt in to capturing if the user is already opted in", () => { + hasOptedInSpy.mockReturnValueOnce(true); + handleCaptureConsent(true); + + expect(optInSpy).not.toHaveBeenCalled(); + expect(optOutSpy).not.toHaveBeenCalled(); + }); + + it("should not opt out of capturing if the user is already opted out", () => { + hasOptedOutSpy.mockReturnValueOnce(true); + handleCaptureConsent(false); + + expect(optOutSpy).not.toHaveBeenCalled(); + expect(optInSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5f72fe6d0b96..89613585652a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@remix-run/node": "^2.11.2", "@remix-run/react": "^2.11.2", "@remix-run/serve": "^2.11.2", + "@tanstack/react-query": "^5.60.5", "@vitejs/plugin-react": "^4.3.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.4.0", @@ -50,6 +51,7 @@ "@remix-run/dev": "^2.11.2", "@remix-run/testing": "^2.11.2", "@tailwindcss/typography": "^0.5.15", + "@tanstack/eslint-plugin-query": "^5.60.1", "@testing-library/jest-dom": "^6.6.1", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", @@ -5812,6 +5814,143 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20" } }, + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.60.1", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.60.1.tgz", + "integrity": "sha512-oCaWtFKa6WwX14fm/Sp486eTFXXgadiDzEYxhM/tiAlM+xzvPwp6ZHgR6sndmvYK+s/jbksDCTLIPS0PCH8L2g==", + "dev": true, + "dependencies": { + "@typescript-eslint/utils": "^8.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/scope-manager": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.14.0.tgz", + "integrity": "sha512-aBbBrnW9ARIDn92Zbo7rguLnqQ/pOrUguVpbUwzOhkFg2npFDwTgPGqFqE0H5feXcOoJOfX3SxlJaKEVtq54dw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.14.0", + "@typescript-eslint/visitor-keys": "8.14.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/types": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.14.0.tgz", + "integrity": "sha512-yjeB9fnO/opvLJFAsPNYlKPnEM8+z4og09Pk504dkqonT02AyL5Z9SSqlE0XqezS93v6CXn49VHvB2G7XSsl0g==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.14.0.tgz", + "integrity": "sha512-OPXPLYKGZi9XS/49rdaCbR5j/S14HazviBlUQFvSKz3npr3NikF+mrgK7CFVur6XEt95DZp/cmke9d5i3vtVnQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.14.0", + "@typescript-eslint/visitor-keys": "8.14.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/utils": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.14.0.tgz", + "integrity": "sha512-OGqj6uB8THhrHj0Fk27DcHPojW7zKwKkPmHXHvQ58pLYp4hy8CSUdTKykKeh+5vFqTTVmjz0zCOOPKRovdsgHA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.14.0", + "@typescript-eslint/types": "8.14.0", + "@typescript-eslint/typescript-estree": "8.14.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.14.0.tgz", + "integrity": "sha512-vG0XZo8AdTH9OE6VFRwAZldNc7qtJ/6NLGWak+BtENuEUXGZgFpihILPiBvKXvJ2nFu27XNGC6rKiwuaoMbYzQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.14.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.60.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.60.5.tgz", + "integrity": "sha512-jiS1aC3XI3BJp83ZiTuDLerTmn9P3U95r6p+6/SNauLJaYxfIC4dMuWygwnBHIZxjn2zJqEpj3nysmPieoxfPQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.60.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.60.5.tgz", + "integrity": "sha512-M77bOsPwj1wYE56gk7iJvxGAr4IC12NWdIDhT+Eo8ldkWRHMvIR8I/rufIvT1OXoV/bl7EECwuRuMlxxWtvW2Q==", + "dependencies": { + "@tanstack/query-core": "5.60.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8e134ec5f0e1..5b7d375b692f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@remix-run/node": "^2.11.2", "@remix-run/react": "^2.11.2", "@remix-run/serve": "^2.11.2", + "@tanstack/react-query": "^5.60.5", "@vitejs/plugin-react": "^4.3.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.4.0", @@ -76,6 +77,7 @@ "@remix-run/dev": "^2.11.2", "@remix-run/testing": "^2.11.2", "@tailwindcss/typography": "^0.5.15", + "@tanstack/eslint-plugin-query": "^5.60.1", "@testing-library/jest-dom": "^6.6.1", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 53a48004433d..cfbc10779e14 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: "http://127.0.0.1:3000", + baseURL: "http://localhost:3001/", /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", @@ -72,8 +72,8 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: "npm run dev:mock -- --port 3000", - url: "http://127.0.0.1:3000", + command: "npm run dev:mock -- --port 3001", + url: "http://localhost:3001/", reuseExistingServer: !process.env.CI, }, }); diff --git a/frontend/src/api/github.ts b/frontend/src/api/github.ts index 2a0b5c509254..1cd3c7587cc2 100644 --- a/frontend/src/api/github.ts +++ b/frontend/src/api/github.ts @@ -27,82 +27,19 @@ export const isGitHubErrorReponse = >( */ export const retrieveGitHubUserRepositories = async ( token: string, - per_page = 30, page = 1, + per_page = 30, ): Promise => { const url = new URL("https://api.github.com/user/repos"); url.searchParams.append("sort", "pushed"); // sort by most recently pushed - url.searchParams.append("per_page", per_page.toString()); url.searchParams.append("page", page.toString()); + url.searchParams.append("per_page", per_page.toString()); return fetch(url.toString(), { headers: generateGitHubAPIHeaders(token), }); }; -/** - * Given a GitHub token, retrieves all repositories of the authenticated user - * @param token The GitHub token - * @returns A list of repositories or an error response - */ -export const retrieveAllGitHubUserRepositories = async ( - token: string, -): Promise => { - const repositories: GitHubRepository[] = []; - - // Fetch the first page to extract the last page number and get the first batch of data - const firstPageResponse = await retrieveGitHubUserRepositories(token, 100, 1); - - if (!firstPageResponse.ok) { - return { - message: "Failed to fetch repositories", - documentation_url: - "https://docs.github.com/rest/reference/repos#list-repositories-for-the-authenticated-user", - status: firstPageResponse.status, - }; - } - - const firstPageData = await firstPageResponse.json(); - repositories.push(...firstPageData); - - // Check for pagination and extract the last page number - const link = firstPageResponse.headers.get("link"); - const lastPageMatch = link?.match(/page=(\d+)>; rel="last"/); - const lastPage = lastPageMatch ? parseInt(lastPageMatch[1], 10) : 1; - - // If there is only one page, return the fetched repositories - if (lastPage === 1) { - return repositories; - } - - // Create an array of promises for the remaining pages - const promises = []; - for (let page = 2; page <= lastPage; page += 1) { - promises.push(retrieveGitHubUserRepositories(token, 100, page)); - } - - // Fetch all pages in parallel - const responses = await Promise.all(promises); - - for (const response of responses) { - if (response.ok) { - // TODO: Is there a way to avoid using await within a loop? - // eslint-disable-next-line no-await-in-loop - const data = await response.json(); - repositories.push(...data); - } else { - return { - message: "Failed to fetch repositories", - documentation_url: - "https://docs.github.com/rest/reference/repos#list-repositories-for-the-authenticated-user", - status: response.status, - }; - } - } - - return repositories; -}; - /** * Given a GitHub token, retrieves the authenticated user * @param token The GitHub token @@ -114,6 +51,11 @@ export const retrieveGitHubUser = async ( const response = await fetch("https://api.github.com/user", { headers: generateGitHubAPIHeaders(token), }); + + if (!response.ok) { + throw new Error("Failed to retrieve user data"); + } + const data = await response.json(); if (!isGitHubErrorReponse(data)) { @@ -149,5 +91,9 @@ export const retrieveLatestGitHubCommit = async ( headers: generateGitHubAPIHeaders(token), }); + if (!response.ok) { + throw new Error("Failed to retrieve latest commit"); + } + return response.json(); }; diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index 33f08d94f21e..20e7843befca 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -1,5 +1,4 @@ import { request } from "#/services/api"; -import { cache } from "#/utils/cache"; import { SaveFileSuccessResponse, FileUploadSuccessResponse, @@ -17,13 +16,13 @@ class OpenHands { * @returns List of models available */ static async getModels(): Promise { - const cachedData = cache.get("models"); - if (cachedData) return cachedData; + const response = await fetch("/api/options/models"); - const data = await request("/api/options/models"); - cache.set("models", data); + if (!response.ok) { + throw new Error("Failed to fetch models"); + } - return data; + return response.json(); } /** @@ -31,13 +30,13 @@ class OpenHands { * @returns List of agents available */ static async getAgents(): Promise { - const cachedData = cache.get("agents"); - if (cachedData) return cachedData; + const response = await fetch("/api/options/agents"); - const data = await request(`/api/options/agents`); - cache.set("agents", data); + if (!response.ok) { + throw new Error("Failed to fetch agents"); + } - return data; + return response.json(); } /** @@ -45,23 +44,23 @@ class OpenHands { * @returns List of security analyzers available */ static async getSecurityAnalyzers(): Promise { - const cachedData = cache.get("agents"); - if (cachedData) return cachedData; + const response = await fetch("/api/options/security-analyzers"); - const data = await request(`/api/options/security-analyzers`); - cache.set("security-analyzers", data); + if (!response.ok) { + throw new Error("Failed to fetch security analyzers"); + } - return data; + return response.json(); } static async getConfig(): Promise { - const cachedData = cache.get("config"); - if (cachedData) return cachedData; + const response = await fetch("/config.json"); - const data = await request("/config.json"); - cache.set("config", data); + if (!response.ok) { + throw new Error("Failed to fetch config"); + } - return data; + return response.json(); } /** @@ -69,10 +68,21 @@ class OpenHands { * @param path Path to list files from * @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace */ - static async getFiles(path?: string): Promise { - let url = "/api/list-files"; - if (path) url += `?path=${encodeURIComponent(path)}`; - return request(url); + static async getFiles(token: string, path?: string): Promise { + const url = new URL("/api/list-files", window.location.origin); + if (path) url.searchParams.append("path", path); + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch files"); + } + + return response.json(); } /** @@ -80,9 +90,21 @@ class OpenHands { * @param path Full path of the file to retrieve * @returns Content of the file */ - static async getFile(path: string): Promise { - const url = `/api/select-file?file=${encodeURIComponent(path)}`; - const data = await request(url); + static async getFile(token: string, path: string): Promise { + const url = new URL("/api/select-file", window.location.origin); + url.searchParams.append("file", path); + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch file"); + } + + const data = await response.json(); return data.code; } @@ -93,16 +115,32 @@ class OpenHands { * @returns Success message or error message */ static async saveFile( + token: string, path: string, content: string, - ): Promise { - return request(`/api/save-file`, { + ): Promise { + const response = await fetch("/api/save-file", { method: "POST", body: JSON.stringify({ filePath: path, content }), headers: { "Content-Type": "application/json", + Authorization: `Bearer ${token}`, }, }); + + if (!response.ok) { + throw new Error("Failed to save file"); + } + + const data = (await response.json()) as + | SaveFileSuccessResponse + | ErrorResponse; + + if ("error" in data) { + throw new Error(data.error); + } + + return data; } /** @@ -111,24 +149,33 @@ class OpenHands { * @returns Success message or error message */ static async uploadFiles( - file: File[], - ): Promise { + token: string, + files: File[], + ): Promise { const formData = new FormData(); - file.forEach((f) => formData.append("files", f)); + files.forEach((file) => formData.append("files", file)); - return request(`/api/upload-files`, { + const response = await fetch("/api/upload-files", { method: "POST", body: formData, + headers: { + Authorization: `Bearer ${token}`, + }, }); - } - /** - * Get the blob of the workspace zip - * @returns Blob of the workspace zip - */ - static async getWorkspaceZip(): Promise { - const response = await request(`/api/zip-directory`, {}, false, true); - return response.blob(); + if (!response.ok) { + throw new Error("Failed to upload files"); + } + + const data = (await response.json()) as + | FileUploadSuccessResponse + | ErrorResponse; + + if ("error" in data) { + throw new Error(data.error); + } + + return data; } /** @@ -136,14 +183,53 @@ class OpenHands { * @param data Feedback data * @returns The stored feedback data */ - static async submitFeedback(data: Feedback): Promise { - return request(`/api/submit-feedback`, { + static async submitFeedback( + token: string, + feedback: Feedback, + ): Promise { + const response = await fetch("/api/submit-feedback", { method: "POST", - body: JSON.stringify(data), + body: JSON.stringify(feedback), headers: { "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to submit feedback"); + } + + return response.json(); + } + + /** + * Authenticate with GitHub token + * @returns Response with authentication status and user info if successful + */ + static async authenticate( + gitHubToken: string, + appMode: GetConfigResponse["APP_MODE"], + ): Promise { + if (appMode === "oss") return true; + + const response = await fetch("/api/authenticate", { + method: "POST", + headers: { + "X-GitHub-Token": gitHubToken, }, }); + + return response.ok; + } + + /** + * Get the blob of the workspace zip + * @returns Blob of the workspace zip + */ + static async getWorkspaceZip(): Promise { + const response = await request(`/api/zip-directory`, {}, false, true); + return response.blob(); } /** @@ -153,27 +239,19 @@ class OpenHands { static async getGitHubAccessToken( code: string, ): Promise { - return request(`/api/github/callback`, { + const response = await fetch("/api/github/callback", { method: "POST", body: JSON.stringify({ code }), headers: { "Content-Type": "application/json", }, }); - } - /** - * Authenticate with GitHub token - * @returns Response with authentication status and user info if successful - */ - static async authenticate(): Promise { - return request( - `/api/authenticate`, - { - method: "POST", - }, - true, - ); + if (!response.ok) { + throw new Error("Failed to get GitHub access token"); + } + + return response.json(); } /** diff --git a/frontend/src/components/analytics-consent-form-modal.tsx b/frontend/src/components/analytics-consent-form-modal.tsx index e122b9e8a9bf..b5ea03810f4c 100644 --- a/frontend/src/components/analytics-consent-form-modal.tsx +++ b/frontend/src/components/analytics-consent-form-modal.tsx @@ -1,4 +1,3 @@ -import { useFetcher } from "@remix-run/react"; import { ModalBackdrop } from "./modals/modal-backdrop"; import ModalBody from "./modals/ModalBody"; import ModalButton from "./buttons/ModalButton"; @@ -6,15 +5,31 @@ import { BaseModalTitle, BaseModalDescription, } from "./modals/confirmation-modals/BaseModal"; +import { handleCaptureConsent } from "#/utils/handle-capture-consent"; -export function AnalyticsConsentFormModal() { - const fetcher = useFetcher({ key: "set-consent" }); +interface AnalyticsConsentFormModalProps { + onClose: () => void; +} + +export function AnalyticsConsentFormModal({ + onClose, +}: AnalyticsConsentFormModalProps) { + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const analytics = formData.get("analytics") === "on"; + + handleCaptureConsent(analytics); + localStorage.setItem("analytics-consent", analytics.toString()); + + onClose(); + }; return ( - @@ -36,7 +51,7 @@ export function AnalyticsConsentFormModal() { className="bg-primary text-white w-full hover:opacity-80" /> - + ); } diff --git a/frontend/src/components/chat-interface.tsx b/frontend/src/components/chat-interface.tsx index f0004bd749d4..b53c668c0c8f 100644 --- a/frontend/src/components/chat-interface.tsx +++ b/frontend/src/components/chat-interface.tsx @@ -1,7 +1,6 @@ import { useDispatch, useSelector } from "react-redux"; import React from "react"; import posthog from "posthog-js"; -import { useRouteLoaderData } from "@remix-run/react"; import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; import { ChatMessage } from "./chat-message"; import { FeedbackActions } from "./feedback-actions"; @@ -27,22 +26,22 @@ import { WsClientProviderStatus, } from "#/context/ws-client-provider"; import OpenHands from "#/api/open-hands"; -import { clientLoader } from "#/routes/_oh"; import { downloadWorkspace } from "#/utils/download-workspace"; import { SuggestionItem } from "./suggestion-item"; +import { useAuth } from "#/context/auth-context"; const isErrorMessage = ( message: Message | ErrorMessage, ): message is ErrorMessage => "error" in message; export function ChatInterface() { + const { gitHubToken } = useAuth(); const { send, status, isLoadingMessages } = useWsClient(); const dispatch = useDispatch(); const scrollRef = React.useRef(null); const { scrollDomToBottom, onChatBodyScroll, hitBottom } = useScrollToBottom(scrollRef); - const rootLoaderData = useRouteLoaderData("routes/_oh"); const { messages } = useSelector((state: RootState) => state.chat); const { curAgentState } = useSelector((state: RootState) => state.agent); @@ -175,7 +174,7 @@ export function ChatInterface() { {(curAgentState === AgentState.AWAITING_USER_INPUT || curAgentState === AgentState.FINISHED) && (

- {rootLoaderData?.ghToken ? ( + {gitHubToken ? ( void; @@ -19,22 +18,21 @@ export function Controls({ showSecurityLock, lastCommitData, }: ControlsProps) { - const rootData = useRouteLoaderData("routes/_oh"); - const appData = useRouteLoaderData("routes/_oh.app"); + const { gitHubToken } = useAuth(); + const { selectedRepository } = useSelector( + (state: RootState) => state.initalQuery, + ); const projectMenuCardData = React.useMemo( () => - rootData?.user && - !isGitHubErrorReponse(rootData.user) && - appData?.repo && - lastCommitData + selectedRepository && lastCommitData ? { - avatar: rootData.user.avatar_url, - repoName: appData.repo, + repoName: selectedRepository, lastCommit: lastCommitData, + avatar: null, // TODO: fetch repo avatar } : null, - [rootData, appData, lastCommitData], + [selectedRepository, lastCommitData], ); return ( @@ -55,7 +53,7 @@ export function Controls({
diff --git a/frontend/src/components/event-handler.tsx b/frontend/src/components/event-handler.tsx index 930bbafc840f..014035ed2998 100644 --- a/frontend/src/components/event-handler.tsx +++ b/frontend/src/components/event-handler.tsx @@ -1,12 +1,6 @@ import React from "react"; -import { - useFetcher, - useLoaderData, - useRouteLoaderData, -} from "@remix-run/react"; import { useDispatch, useSelector } from "react-redux"; import toast from "react-hot-toast"; - import posthog from "posthog-js"; import { useWsClient, @@ -24,17 +18,18 @@ import { clearSelectedRepository, setImportedProjectZip, } from "#/state/initial-query-slice"; -import { clientLoader as appClientLoader } from "#/routes/_oh.app"; import store, { RootState } from "#/store"; import { createChatMessage } from "#/services/chatService"; -import { clientLoader as rootClientLoader } from "#/routes/_oh"; import { isGitHubErrorReponse } from "#/api/github"; -import OpenHands from "#/api/open-hands"; import { base64ToBlob } from "#/utils/base64-to-blob"; import { setCurrentAgentState } from "#/state/agentSlice"; import AgentState from "#/types/AgentState"; -import { getSettings } from "#/services/settings"; import { generateAgentStateChangeEvent } from "#/services/agentStateService"; +import { useGitHubUser } from "#/hooks/query/use-github-user"; +import { useUploadFiles } from "#/hooks/mutation/use-upload-files"; +import { useAuth } from "#/context/auth-context"; +import { useEndSession } from "#/hooks/use-end-session"; +import { useUserPrefs } from "#/context/user-prefs-context"; interface ServerError { error: boolean | string; @@ -48,41 +43,48 @@ const isErrorObservation = (data: object): data is ErrorObservation => "observation" in data && data.observation === "error"; export function EventHandler({ children }: React.PropsWithChildren) { + const { setToken, gitHubToken } = useAuth(); + const { settings } = useUserPrefs(); const { events, status, send } = useWsClient(); const statusRef = React.useRef(null); const runtimeActive = status === WsClientProviderStatus.ACTIVE; - const fetcher = useFetcher(); const dispatch = useDispatch(); const { files, importedProjectZip, initialQuery } = useSelector( (state: RootState) => state.initalQuery, ); - const { ghToken, repo } = useLoaderData(); + const endSession = useEndSession(); + + // FIXME: Bad practice - should be handled with state + const { selectedRepository } = useSelector( + (state: RootState) => state.initalQuery, + ); + + const { data: user } = useGitHubUser(); + const { mutate: uploadFiles } = useUploadFiles(); const sendInitialQuery = (query: string, base64Files: string[]) => { const timestamp = new Date().toISOString(); send(createChatMessage(query, base64Files, timestamp)); }; - const data = useRouteLoaderData("routes/_oh"); const userId = React.useMemo(() => { - if (data?.user && !isGitHubErrorReponse(data.user)) return data.user.id; + if (user && !isGitHubErrorReponse(user)) return user.id; return null; - }, [data?.user]); - const userSettings = getSettings(); + }, [user]); React.useEffect(() => { if (!events.length) { return; } const event = events[events.length - 1]; - if (event.token) { - fetcher.submit({ token: event.token as string }, { method: "post" }); + if (event.token && typeof event.token === "string") { + setToken(event.token); return; } if (isServerError(event)) { if (event.error_code === 401) { toast.error("Session expired."); - fetcher.submit({}, { method: "POST", action: "/end-session" }); + endSession(); return; } @@ -120,9 +122,9 @@ export function EventHandler({ children }: React.PropsWithChildren) { if (status === WsClientProviderStatus.ACTIVE) { let additionalInfo = ""; - if (ghToken && repo) { - send(getCloneRepoCommand(ghToken, repo)); - additionalInfo = `Repository ${repo} has been cloned to /workspace. Please check the /workspace for files.`; + if (gitHubToken && selectedRepository) { + send(getCloneRepoCommand(gitHubToken, selectedRepository)); + additionalInfo = `Repository ${selectedRepository} has been cloned to /workspace. Please check the /workspace for files.`; dispatch(clearSelectedRepository()); // reset selected repository; maybe better to move this to '/'? } // if there's an uploaded project zip, add it to the chat @@ -157,35 +159,35 @@ export function EventHandler({ children }: React.PropsWithChildren) { }, [status]); React.useEffect(() => { - if (runtimeActive && userId && ghToken) { + if (runtimeActive && userId && gitHubToken) { // Export if the user valid, this could happen mid-session so it is handled here - send(getGitHubTokenCommand(ghToken)); + send(getGitHubTokenCommand(gitHubToken)); } - }, [userId, ghToken, runtimeActive]); + }, [userId, gitHubToken, runtimeActive]); React.useEffect(() => { - (async () => { - if (runtimeActive && importedProjectZip) { - // upload files action - try { - const blob = base64ToBlob(importedProjectZip); - const file = new File([blob], "imported-project.zip", { - type: blob.type, - }); - await OpenHands.uploadFiles([file]); - dispatch(setImportedProjectZip(null)); - } catch (error) { - toast.error("Failed to upload project files."); - } - } - })(); + if (runtimeActive && importedProjectZip) { + const blob = base64ToBlob(importedProjectZip); + const file = new File([blob], "imported-project.zip", { + type: blob.type, + }); + uploadFiles( + { files: [file] }, + { + onError: () => { + toast.error("Failed to upload project files."); + }, + }, + ); + dispatch(setImportedProjectZip(null)); + } }, [runtimeActive, importedProjectZip]); React.useEffect(() => { - if (userSettings.LLM_API_KEY) { + if (settings.LLM_API_KEY) { posthog.capture("user_activated"); } - }, [userSettings.LLM_API_KEY]); + }, [settings.LLM_API_KEY]); return children; } diff --git a/frontend/src/components/feedback-form.tsx b/frontend/src/components/feedback-form.tsx index 078e4b0ccca6..bc68de9bffc3 100644 --- a/frontend/src/components/feedback-form.tsx +++ b/frontend/src/components/feedback-form.tsx @@ -2,7 +2,7 @@ import React from "react"; import hotToast from "react-hot-toast"; import ModalButton from "./buttons/ModalButton"; import { Feedback } from "#/api/open-hands.types"; -import OpenHands from "#/api/open-hands"; +import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback"; const FEEDBACK_VERSION = "1.0"; const VIEWER_PAGE = "https://www.all-hands.dev/share"; @@ -13,8 +13,6 @@ interface FeedbackFormProps { } export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) { - const [isSubmitting, setIsSubmitting] = React.useState(false); - const copiedToClipboardToast = () => { hotToast("Password copied to clipboard", { icon: "📋", @@ -53,10 +51,11 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) { ); }; + const { mutate: submitFeedback, isPending } = useSubmitFeedback(); + const handleSubmit = async (event: React.FormEvent) => { event?.preventDefault(); const formData = new FormData(event.currentTarget); - setIsSubmitting(true); const email = formData.get("email")?.toString() || ""; const permissions = (formData.get("permissions")?.toString() || @@ -71,11 +70,17 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) { token: "", }; - const response = await OpenHands.submitFeedback(feedback); - const { message, feedback_id, password } = response.body; // eslint-disable-line - const link = `${VIEWER_PAGE}?share_id=${feedback_id}`; - shareFeedbackToast(message, link, password); - setIsSubmitting(false); + submitFeedback( + { feedback }, + { + onSuccess: (data) => { + const { message, feedback_id, password } = data.body; // eslint-disable-line + const link = `${VIEWER_PAGE}?share_id=${feedback_id}`; + shareFeedbackToast(message, link, password); + onClose(); + }, + }, + ); }; return ( @@ -109,13 +114,13 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
void; @@ -95,13 +94,9 @@ function ExplorerActions({ interface FileExplorerProps { isOpen: boolean; onToggle: () => void; - error: string | null; } -function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) { - const { revalidate } = useRevalidator(); - - const { paths, setPaths } = useFiles(); +function FileExplorer({ isOpen, onToggle }: FileExplorerProps) { const [isDragging, setIsDragging] = React.useState(false); const { curAgentState } = useSelector((state: RootState) => state.agent); @@ -112,64 +107,59 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) { fileInputRef.current?.click(); // Trigger the file browser }; - const refreshWorkspace = () => { - if ( - curAgentState === AgentState.LOADING || - curAgentState === AgentState.STOPPED - ) { - return; - } - dispatch(setRefreshID(Math.random())); - OpenHands.getFiles().then(setPaths); - revalidate(); - }; + const { data: paths, refetch, error } = useListFiles(); - const uploadFileData = async (files: FileList) => { - try { - const result = await OpenHands.uploadFiles(Array.from(files)); + const handleUploadSuccess = (data: FileUploadSuccessResponse) => { + const uploadedCount = data.uploaded_files.length; + const skippedCount = data.skipped_files.length; - if (isOpenHandsErrorResponse(result)) { - // Handle error response - toast.error( - `upload-error-${new Date().getTime()}`, - result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE), - ); - return; - } + if (uploadedCount > 0) { + toast.success( + `upload-success-${new Date().getTime()}`, + t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, { + count: uploadedCount, + }), + ); + } - const uploadedCount = result.uploaded_files.length; - const skippedCount = result.skipped_files.length; + if (skippedCount > 0) { + const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, { + count: skippedCount, + }); + toast.info(message); + } - if (uploadedCount > 0) { - toast.success( - `upload-success-${new Date().getTime()}`, - t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, { - count: uploadedCount, - }), - ); - } + if (uploadedCount === 0 && skippedCount === 0) { + toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE)); + } + }; - if (skippedCount > 0) { - const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, { - count: skippedCount, - }); - toast.info(message); - } + const handleUploadError = (e: Error) => { + toast.error( + `upload-error-${new Date().getTime()}`, + e.message || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE), + ); + }; - if (uploadedCount === 0 && skippedCount === 0) { - toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE)); - } + const { mutate: uploadFiles } = useUploadFiles(); - refreshWorkspace(); - } catch (e) { - // Handle unexpected errors (network issues, etc.) - toast.error( - `upload-error-${new Date().getTime()}`, - t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE), - ); + const refreshWorkspace = () => { + if ( + curAgentState !== AgentState.LOADING && + curAgentState !== AgentState.STOPPED + ) { + refetch(); } }; + const uploadFileData = (files: FileList) => { + uploadFiles( + { files: Array.from(files) }, + { onSuccess: handleUploadSuccess, onError: handleUploadError }, + ); + refreshWorkspace(); + }; + const handleVSCodeClick = async (e: React.MouseEvent) => { e.preventDefault(); try { @@ -265,13 +255,13 @@ function FileExplorer({ error, isOpen, onToggle }: FileExplorerProps) { {!error && (
- +
)} {error && (
-

{error}

+

{error.message}

)} {isOpen && ( diff --git a/frontend/src/components/file-explorer/TreeNode.tsx b/frontend/src/components/file-explorer/TreeNode.tsx index b3aa3c28335c..d65eb07148ad 100644 --- a/frontend/src/components/file-explorer/TreeNode.tsx +++ b/frontend/src/components/file-explorer/TreeNode.tsx @@ -1,12 +1,10 @@ import React from "react"; -import { useSelector } from "react-redux"; -import toast from "react-hot-toast"; -import { RootState } from "#/store"; import FolderIcon from "../FolderIcon"; import FileIcon from "../FileIcons"; -import OpenHands from "#/api/open-hands"; import { useFiles } from "#/context/files"; import { cn } from "#/utils/utils"; +import { useListFiles } from "#/hooks/query/use-list-files"; +import { useListFile } from "#/hooks/query/use-list-file"; interface TitleProps { name: string; @@ -44,51 +42,35 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) { selectedPath, } = useFiles(); const [isOpen, setIsOpen] = React.useState(defaultOpen); - const [children, setChildren] = React.useState(null); - const refreshID = useSelector((state: RootState) => state.code.refreshID); - - const fileParts = path.split("/"); - const filename = - fileParts[fileParts.length - 1] || fileParts[fileParts.length - 2]; const isDirectory = path.endsWith("/"); - const refreshChildren = async () => { - if (!isDirectory || !isOpen) { - setChildren(null); - return; - } + const { data: paths } = useListFiles({ + path, + enabled: isDirectory && isOpen, + }); - try { - const newChildren = await OpenHands.getFiles(path); - setChildren(newChildren); - } catch (error) { - toast.error("Failed to fetch files"); - } - }; + const { data: fileContent, refetch } = useListFile({ path }); React.useEffect(() => { - (async () => { - await refreshChildren(); - })(); - }, [refreshID, isOpen]); - - const handleClick = async () => { - if (isDirectory) { - setIsOpen((prev) => !prev); - } else { + if (fileContent) { const code = modifiedFiles[path] || files[path]; - - try { - const fetchedCode = await OpenHands.getFile(path); - setSelectedPath(path); - if (!code || fetchedCode !== files[path]) { - setFileContent(path, fetchedCode); - } - } catch (error) { - toast.error("Failed to fetch file"); + if (!code || fileContent !== files[path]) { + setFileContent(path, fileContent); } } + }, [fileContent, path]); + + const fileParts = path.split("/"); + const filename = + fileParts[fileParts.length - 1] || fileParts[fileParts.length - 2]; + + const handleClick = async () => { + if (isDirectory) setIsOpen((prev) => !prev); + else { + setSelectedPath(path); + await refetch(); + } }; return ( @@ -116,9 +98,9 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) { )} - {isOpen && children && ( + {isOpen && paths && (
- {children.map((child, index) => ( + {paths.map((child, index) => ( ))}
diff --git a/frontend/src/components/form/settings-form.tsx b/frontend/src/components/form/settings-form.tsx index 02a042e8a5c4..ec235a2a780e 100644 --- a/frontend/src/components/form/settings-form.tsx +++ b/frontend/src/components/form/settings-form.tsx @@ -4,19 +4,26 @@ import { Input, Switch, } from "@nextui-org/react"; -import { useFetcher, useLocation, useNavigate } from "@remix-run/react"; +import { useLocation } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import clsx from "clsx"; import React from "react"; +import posthog from "posthog-js"; import { organizeModelsAndProviders } from "#/utils/organizeModelsAndProviders"; import { ModelSelector } from "#/components/modals/settings/ModelSelector"; -import { Settings } from "#/services/settings"; +import { getDefaultSettings, Settings } from "#/services/settings"; import { ModalBackdrop } from "#/components/modals/modal-backdrop"; -import { clientAction } from "#/routes/settings"; import { extractModelAndProvider } from "#/utils/extractModelAndProvider"; import ModalButton from "../buttons/ModalButton"; import { DangerModal } from "../modals/confirmation-modals/danger-modal"; import { I18nKey } from "#/i18n/declaration"; +import { + extractSettings, + saveSettingsView, + updateSettingsVersion, +} from "#/utils/settings-utils"; +import { useEndSession } from "#/hooks/use-end-session"; +import { useUserPrefs } from "#/context/user-prefs-context"; interface SettingsFormProps { disabled?: boolean; @@ -35,19 +42,36 @@ export function SettingsForm({ securityAnalyzers, onClose, }: SettingsFormProps) { + const { saveSettings } = useUserPrefs(); + const endSession = useEndSession(); + const location = useLocation(); - const navigate = useNavigate(); const { t } = useTranslation(); - const fetcher = useFetcher(); const formRef = React.useRef(null); - React.useEffect(() => { - if (fetcher.data?.success) { - navigate("/"); + const resetOngoingSession = () => { + if (location.pathname.startsWith("/app")) { + endSession(); onClose(); } - }, [fetcher.data, navigate, onClose]); + }; + + const handleFormSubmission = (formData: FormData) => { + const keys = Array.from(formData.keys()); + const isUsingAdvancedOptions = keys.includes("use-advanced-options"); + const newSettings = extractSettings(formData); + + saveSettings(newSettings); + saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic"); + updateSettingsVersion(); + resetOngoingSession(); + + posthog.capture("settings_saved", { + LLM_MODEL: newSettings.LLM_MODEL, + LLM_API_KEY: newSettings.LLM_API_KEY ? "SET" : "UNSET", + }); + }; const advancedAlreadyInUse = React.useMemo(() => { if (models.length > 0) { @@ -83,20 +107,17 @@ export function SettingsForm({ React.useState(false); const [showWarningModal, setShowWarningModal] = React.useState(false); - const submitForm = (formData: FormData) => { - if (location.pathname === "/app") formData.set("end-session", "true"); - fetcher.submit(formData, { method: "POST", action: "/settings" }); - }; - const handleConfirmResetSettings = () => { - const formData = new FormData(formRef.current ?? undefined); - formData.set("intent", "reset"); - submitForm(formData); + saveSettings(getDefaultSettings()); + resetOngoingSession(); + posthog.capture("settings_reset"); + + onClose(); }; const handleConfirmEndSession = () => { const formData = new FormData(formRef.current ?? undefined); - submitForm(formData); + handleFormSubmission(formData); }; const handleSubmit = (event: React.FormEvent) => { @@ -106,10 +127,11 @@ export function SettingsForm({ if (!apiKey) { setShowWarningModal(true); - } else if (location.pathname === "/app") { + } else if (location.pathname.startsWith("/app")) { setConfirmEndSessionModalOpen(true); } else { - submitForm(formData); + handleFormSubmission(formData); + onClose(); } }; @@ -117,18 +139,15 @@ export function SettingsForm({ const formData = new FormData(formRef.current ?? undefined); const apiKey = formData.get("api-key"); - if (!apiKey) { - setShowWarningModal(true); - } else { - onClose(); - } + if (!apiKey) setShowWarningModal(true); + else onClose(); }; const handleWarningConfirm = () => { setShowWarningModal(false); const formData = new FormData(formRef.current ?? undefined); formData.set("api-key", ""); // Set null value for API key - submitForm(formData); + handleFormSubmission(formData); onClose(); }; @@ -138,11 +157,9 @@ export function SettingsForm({ return (
- @@ -267,9 +284,7 @@ export function SettingsForm({ aria-label="Agent" data-testid="agent-input" name="agent" - defaultSelectedKey={ - fetcher.formData?.get("agent")?.toString() ?? settings.AGENT - } + defaultSelectedKey={settings.AGENT} isClearable={false} inputProps={{ classNames: { @@ -302,10 +317,7 @@ export function SettingsForm({ id="security-analyzer" name="security-analyzer" aria-label="Security Analyzer" - defaultSelectedKey={ - fetcher.formData?.get("security-analyzer")?.toString() ?? - settings.SECURITY_ANALYZER - } + defaultSelectedKey={settings.SECURITY_ANALYZER} inputProps={{ classNames: { inputWrapper: @@ -346,7 +358,7 @@ export function SettingsForm({
- + {confirmResetDefaultsModalOpen && ( diff --git a/frontend/src/components/github-repositories-suggestion-box.tsx b/frontend/src/components/github-repositories-suggestion-box.tsx index 4886513dd487..b00a48c65749 100644 --- a/frontend/src/components/github-repositories-suggestion-box.tsx +++ b/frontend/src/components/github-repositories-suggestion-box.tsx @@ -1,8 +1,5 @@ import React from "react"; -import { - isGitHubErrorReponse, - retrieveAllGitHubUserRepositories, -} from "#/api/github"; +import { isGitHubErrorReponse } from "#/api/github"; import { SuggestionBox } from "#/routes/_oh._index/suggestion-box"; import { ConnectToGitHubModal } from "./modals/connect-to-github-modal"; import { ModalBackdrop } from "./modals/modal-backdrop"; @@ -12,9 +9,7 @@ import GitHubLogo from "#/assets/branding/github-logo.svg?react"; interface GitHubRepositoriesSuggestionBoxProps { handleSubmit: () => void; - repositories: Awaited< - ReturnType - > | null; + repositories: GitHubRepository[]; gitHubAuthUrl: string | null; user: GitHubErrorReponse | GitHubUser | null; } @@ -57,7 +52,7 @@ export function GitHubRepositoriesSuggestionBox({ isLoggedIn ? ( ) : ( void; @@ -28,41 +27,33 @@ function AccountSettingsModal({ gitHubError, analyticsConsent, }: AccountSettingsModalProps) { + const { gitHubToken, setGitHubToken, logout } = useAuth(); + const { saveSettings } = useUserPrefs(); const { t } = useTranslation(); - const data = useRouteLoaderData("routes/_oh"); - const settingsFetcher = useFetcher({ - key: "settings", - }); - const loginFetcher = useFetcher({ key: "login" }); const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); const formData = new FormData(event.currentTarget); - const language = formData.get("language")?.toString(); + const ghToken = formData.get("ghToken")?.toString(); + const language = formData.get("language")?.toString(); const analytics = formData.get("analytics")?.toString() === "on"; - const accountForm = new FormData(); - const loginForm = new FormData(); + if (ghToken) setGitHubToken(ghToken); - accountForm.append("intent", "account"); + // The form returns the language label, so we need to find the corresponding + // language key to save it in the settings if (language) { const languageKey = AvailableLanguages.find( ({ label }) => label === language, )?.value; - accountForm.append("language", languageKey ?? "en"); + + if (languageKey) saveSettings({ LANGUAGE: languageKey }); } - if (ghToken) loginForm.append("ghToken", ghToken); - accountForm.append("analytics", analytics.toString()); - settingsFetcher.submit(accountForm, { - method: "POST", - action: "/settings", - }); - loginFetcher.submit(loginForm, { - method: "POST", - action: "/login", - }); + handleCaptureConsent(analytics); + const ANALYTICS = analytics.toString(); + localStorage.setItem("analytics-consent", ANALYTICS); onClose(); }; @@ -88,7 +79,7 @@ function AccountSettingsModal({ name="ghToken" label="GitHub Token" type="password" - defaultValue={data?.ghToken ?? ""} + defaultValue={gitHubToken ?? ""} /> {t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "} @@ -106,15 +97,12 @@ function AccountSettingsModal({ {t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}

)} - {data?.ghToken && !gitHubError && ( + {gitHubToken && !gitHubError && ( { - settingsFetcher.submit( - {}, - { method: "POST", action: "/logout" }, - ); + logout(); onClose(); }} className="text-danger self-start" @@ -133,10 +121,6 @@ function AccountSettingsModal({
-
- - - -
-
- - - - - - ); -} - -export default ConnectToGitHubByTokenModal; diff --git a/frontend/src/components/modals/connect-to-github-modal.tsx b/frontend/src/components/modals/connect-to-github-modal.tsx index 19cc4ac36ff4..bd0e6b764bef 100644 --- a/frontend/src/components/modals/connect-to-github-modal.tsx +++ b/frontend/src/components/modals/connect-to-github-modal.tsx @@ -1,4 +1,3 @@ -import { useFetcher, useRouteLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import ModalBody from "./ModalBody"; import { CustomInput } from "../form/custom-input"; @@ -7,19 +6,26 @@ import { BaseModalDescription, BaseModalTitle, } from "./confirmation-modals/BaseModal"; -import { clientLoader } from "#/routes/_oh"; -import { clientAction } from "#/routes/login"; import { I18nKey } from "#/i18n/declaration"; +import { useAuth } from "#/context/auth-context"; interface ConnectToGitHubModalProps { onClose: () => void; } export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) { - const data = useRouteLoaderData("routes/_oh"); - const fetcher = useFetcher({ key: "login" }); + const { gitHubToken, setGitHubToken } = useAuth(); const { t } = useTranslation(); + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const ghToken = formData.get("ghToken")?.toString(); + + if (ghToken) setGitHubToken(ghToken); + onClose(); + }; + return (
@@ -40,18 +46,13 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) { } />
- +
@@ -59,7 +60,6 @@ export function ConnectToGitHubModal({ onClose }: ConnectToGitHubModalProps) { testId="connect-to-github" type="submit" text={t(I18nKey.CONNECT_TO_GITHUB_MODAL$CONNECT)} - disabled={fetcher.state === "submitting"} className="bg-[#791B80] w-full" />
- +
); } diff --git a/frontend/src/components/project-menu/ProjectMenuCard.tsx b/frontend/src/components/project-menu/ProjectMenuCard.tsx index 1a32c2f802d1..a840732cea04 100644 --- a/frontend/src/components/project-menu/ProjectMenuCard.tsx +++ b/frontend/src/components/project-menu/ProjectMenuCard.tsx @@ -17,7 +17,7 @@ import { useWsClient } from "#/context/ws-client-provider"; interface ProjectMenuCardProps { isConnectedToGitHub: boolean; githubData: { - avatar: string; + avatar: string | null; repoName: string; lastCommit: GitHubCommit; } | null; diff --git a/frontend/src/components/project-menu/project-menu-details.tsx b/frontend/src/components/project-menu/project-menu-details.tsx index 6b5382a43689..8bb67a2ec8ba 100644 --- a/frontend/src/components/project-menu/project-menu-details.tsx +++ b/frontend/src/components/project-menu/project-menu-details.tsx @@ -5,7 +5,7 @@ import { I18nKey } from "#/i18n/declaration"; interface ProjectMenuDetailsProps { repoName: string; - avatar: string; + avatar: string | null; lastCommit: GitHubCommit; } @@ -23,7 +23,7 @@ export function ProjectMenuDetails({ rel="noreferrer noopener" className="flex items-center gap-2" > - + {avatar && } {repoName}
diff --git a/frontend/src/components/user-actions.tsx b/frontend/src/components/user-actions.tsx index d605cb895d96..c3bfc4bd02e4 100644 --- a/frontend/src/components/user-actions.tsx +++ b/frontend/src/components/user-actions.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { useFetcher } from "@remix-run/react"; import { AccountSettingsContextMenu } from "./context-menu/account-settings-context-menu"; import { UserAvatar } from "./user-avatar"; @@ -14,8 +13,6 @@ export function UserActions({ onLogout, user, }: UserActionsProps) { - const loginFetcher = useFetcher({ key: "login" }); - const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] = React.useState(false); @@ -39,11 +36,7 @@ export function UserActions({ return (
- + {accountContextMenuIsVisible && ( void; + setGitHubToken: (token: string | null) => void; + clearToken: () => void; + clearGitHubToken: () => void; + logout: () => void; +} + +const AuthContext = React.createContext(undefined); + +function AuthProvider({ children }: React.PropsWithChildren) { + const [tokenState, setTokenState] = React.useState(() => + localStorage.getItem("token"), + ); + const [gitHubTokenState, setGitHubTokenState] = React.useState( + () => localStorage.getItem("ghToken"), + ); + + React.useLayoutEffect(() => { + setTokenState(localStorage.getItem("token")); + setGitHubTokenState(localStorage.getItem("ghToken")); + }); + + const setToken = (token: string | null) => { + setTokenState(token); + + if (token) localStorage.setItem("token", token); + else localStorage.removeItem("token"); + }; + + const setGitHubToken = (token: string | null) => { + setGitHubTokenState(token); + + if (token) localStorage.setItem("ghToken", token); + else localStorage.removeItem("ghToken"); + }; + + const clearToken = () => { + setTokenState(null); + localStorage.removeItem("token"); + }; + + const clearGitHubToken = () => { + setGitHubTokenState(null); + localStorage.removeItem("ghToken"); + }; + + const logout = () => { + clearGitHubToken(); + posthog.reset(); + }; + + const value = React.useMemo( + () => ({ + token: tokenState, + gitHubToken: gitHubTokenState, + setToken, + setGitHubToken, + clearToken, + clearGitHubToken, + logout, + }), + [tokenState, gitHubTokenState], + ); + + return {children}; +} + +function useAuth() { + const context = React.useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within a AuthProvider"); + } + return context; +} + +export { AuthProvider, useAuth }; diff --git a/frontend/src/context/user-prefs-context.tsx b/frontend/src/context/user-prefs-context.tsx new file mode 100644 index 000000000000..e3573c9234c0 --- /dev/null +++ b/frontend/src/context/user-prefs-context.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { + getSettings, + Settings, + saveSettings as updateAndSaveSettingsToLocalStorage, + settingsAreUpToDate as checkIfSettingsAreUpToDate, +} from "#/services/settings"; + +interface UserPrefsContextType { + settings: Settings; + settingsAreUpToDate: boolean; + saveSettings: (settings: Partial) => void; +} + +const UserPrefsContext = React.createContext( + undefined, +); + +function UserPrefsProvider({ children }: React.PropsWithChildren) { + const [settings, setSettings] = React.useState(getSettings()); + const [settingsAreUpToDate, setSettingsAreUpToDate] = React.useState( + checkIfSettingsAreUpToDate(), + ); + + const saveSettings = (newSettings: Partial) => { + updateAndSaveSettingsToLocalStorage(newSettings); + setSettings(getSettings()); + setSettingsAreUpToDate(checkIfSettingsAreUpToDate()); + }; + + const value = React.useMemo( + () => ({ + settings, + settingsAreUpToDate, + saveSettings, + }), + [settings, settingsAreUpToDate], + ); + + return ( + + {children} + + ); +} + +function useUserPrefs() { + const context = React.useContext(UserPrefsContext); + if (context === undefined) { + throw new Error("useUserPrefs must be used within a UserPrefsProvider"); + } + return context; +} + +export { UserPrefsProvider, useUserPrefs }; diff --git a/frontend/src/entry.client.tsx b/frontend/src/entry.client.tsx index cb8f3d16f0a9..875daf565d4c 100644 --- a/frontend/src/entry.client.tsx +++ b/frontend/src/entry.client.tsx @@ -11,26 +11,23 @@ import { hydrateRoot } from "react-dom/client"; import { Provider } from "react-redux"; import posthog from "posthog-js"; import "./i18n"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import store from "./store"; -import OpenHands from "./api/open-hands"; +import { useConfig } from "./hooks/query/use-config"; +import { AuthProvider } from "./context/auth-context"; +import { UserPrefsProvider } from "./context/user-prefs-context"; function PosthogInit() { - const [key, setKey] = React.useState(null); + const { data: config } = useConfig(); React.useEffect(() => { - OpenHands.getConfig().then((config) => { - setKey(config.POSTHOG_CLIENT_KEY); - }); - }, []); - - React.useEffect(() => { - if (key) { - posthog.init(key, { + if (config?.POSTHOG_CLIENT_KEY) { + posthog.init(config.POSTHOG_CLIENT_KEY, { api_host: "https://us.i.posthog.com", person_profiles: "identified_only", }); } - }, [key]); + }, [config]); return null; } @@ -48,14 +45,22 @@ async function prepareApp() { } } +const queryClient = new QueryClient(); + prepareApp().then(() => startTransition(() => { hydrateRoot( document, - - + + + + + + + + , ); diff --git a/frontend/src/hooks/mutation/use-save-file.ts b/frontend/src/hooks/mutation/use-save-file.ts new file mode 100644 index 000000000000..30edcda21a15 --- /dev/null +++ b/frontend/src/hooks/mutation/use-save-file.ts @@ -0,0 +1,21 @@ +import { useMutation } from "@tanstack/react-query"; +import toast from "react-hot-toast"; +import OpenHands from "#/api/open-hands"; +import { useAuth } from "#/context/auth-context"; + +type SaveFileArgs = { + path: string; + content: string; +}; + +export const useSaveFile = () => { + const { token } = useAuth(); + + return useMutation({ + mutationFn: ({ path, content }: SaveFileArgs) => + OpenHands.saveFile(token || "", path, content), + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-submit-feedback.ts b/frontend/src/hooks/mutation/use-submit-feedback.ts new file mode 100644 index 000000000000..0253b69d559e --- /dev/null +++ b/frontend/src/hooks/mutation/use-submit-feedback.ts @@ -0,0 +1,21 @@ +import { useMutation } from "@tanstack/react-query"; +import toast from "react-hot-toast"; +import { Feedback } from "#/api/open-hands.types"; +import OpenHands from "#/api/open-hands"; +import { useAuth } from "#/context/auth-context"; + +type SubmitFeedbackArgs = { + feedback: Feedback; +}; + +export const useSubmitFeedback = () => { + const { token } = useAuth(); + + return useMutation({ + mutationFn: ({ feedback }: SubmitFeedbackArgs) => + OpenHands.submitFeedback(token || "", feedback), + onError: (error) => { + toast.error(error.message); + }, + }); +}; diff --git a/frontend/src/hooks/mutation/use-upload-files.ts b/frontend/src/hooks/mutation/use-upload-files.ts new file mode 100644 index 000000000000..0f7a31ed3811 --- /dev/null +++ b/frontend/src/hooks/mutation/use-upload-files.ts @@ -0,0 +1,16 @@ +import { useMutation } from "@tanstack/react-query"; +import OpenHands from "#/api/open-hands"; +import { useAuth } from "#/context/auth-context"; + +type UploadFilesArgs = { + files: File[]; +}; + +export const useUploadFiles = () => { + const { token } = useAuth(); + + return useMutation({ + mutationFn: ({ files }: UploadFilesArgs) => + OpenHands.uploadFiles(token || "", files), + }); +}; diff --git a/frontend/src/hooks/query/use-ai-config-options.ts b/frontend/src/hooks/query/use-ai-config-options.ts new file mode 100644 index 000000000000..9e63cf6a8275 --- /dev/null +++ b/frontend/src/hooks/query/use-ai-config-options.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import OpenHands from "#/api/open-hands"; + +const fetchAiConfigOptions = async () => ({ + models: await OpenHands.getModels(), + agents: await OpenHands.getAgents(), + securityAnalyzers: await OpenHands.getSecurityAnalyzers(), +}); + +export const useAIConfigOptions = () => + useQuery({ + queryKey: ["ai-config-options"], + queryFn: fetchAiConfigOptions, + }); diff --git a/frontend/src/hooks/query/use-config.ts b/frontend/src/hooks/query/use-config.ts new file mode 100644 index 000000000000..8b81af13b537 --- /dev/null +++ b/frontend/src/hooks/query/use-config.ts @@ -0,0 +1,8 @@ +import { useQuery } from "@tanstack/react-query"; +import OpenHands from "#/api/open-hands"; + +export const useConfig = () => + useQuery({ + queryKey: ["config"], + queryFn: OpenHands.getConfig, + }); diff --git a/frontend/src/hooks/query/use-github-user.ts b/frontend/src/hooks/query/use-github-user.ts new file mode 100644 index 000000000000..ac0de4acce63 --- /dev/null +++ b/frontend/src/hooks/query/use-github-user.ts @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; +import React from "react"; +import posthog from "posthog-js"; +import { retrieveGitHubUser, isGitHubErrorReponse } from "#/api/github"; +import { useAuth } from "#/context/auth-context"; +import { useConfig } from "./use-config"; + +export const useGitHubUser = () => { + const { gitHubToken } = useAuth(); + const { data: config } = useConfig(); + + const user = useQuery({ + queryKey: ["user", gitHubToken], + queryFn: async () => { + const data = await retrieveGitHubUser(gitHubToken!); + + if (isGitHubErrorReponse(data)) { + throw new Error("Failed to retrieve user data"); + } + + return data; + }, + enabled: !!gitHubToken && !!config?.APP_MODE, + retry: false, + }); + + React.useEffect(() => { + if (user.data) { + posthog.identify(user.data.login, { + company: user.data.company, + name: user.data.name, + email: user.data.email, + user: user.data.login, + mode: config?.APP_MODE || "oss", + }); + } + }, [user.data]); + + return user; +}; diff --git a/frontend/src/hooks/query/use-is-authed.ts b/frontend/src/hooks/query/use-is-authed.ts new file mode 100644 index 000000000000..9f6971b754b8 --- /dev/null +++ b/frontend/src/hooks/query/use-is-authed.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import React from "react"; +import OpenHands from "#/api/open-hands"; +import { useConfig } from "./use-config"; +import { useAuth } from "#/context/auth-context"; + +export const useIsAuthed = () => { + const { gitHubToken } = useAuth(); + const { data: config } = useConfig(); + + const appMode = React.useMemo(() => config?.APP_MODE, [config]); + + return useQuery({ + queryKey: ["user", "authenticated", gitHubToken, appMode], + queryFn: () => OpenHands.authenticate(gitHubToken || "", appMode!), + enabled: !!appMode, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; diff --git a/frontend/src/hooks/query/use-latest-repo-commit.ts b/frontend/src/hooks/query/use-latest-repo-commit.ts new file mode 100644 index 000000000000..3ead53c6c57d --- /dev/null +++ b/frontend/src/hooks/query/use-latest-repo-commit.ts @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; +import { retrieveLatestGitHubCommit, isGitHubErrorReponse } from "#/api/github"; +import { useAuth } from "#/context/auth-context"; + +interface UseLatestRepoCommitConfig { + repository: string | null; +} + +export const useLatestRepoCommit = (config: UseLatestRepoCommitConfig) => { + const { gitHubToken } = useAuth(); + + return useQuery({ + queryKey: ["latest_commit", gitHubToken, config.repository], + queryFn: async () => { + const data = await retrieveLatestGitHubCommit( + gitHubToken!, + config.repository!, + ); + + if (isGitHubErrorReponse(data)) { + throw new Error("Failed to retrieve latest commit"); + } + + return data[0]; + }, + enabled: !!gitHubToken && !!config.repository, + }); +}; diff --git a/frontend/src/hooks/query/use-list-file.ts b/frontend/src/hooks/query/use-list-file.ts new file mode 100644 index 000000000000..074bf6b72963 --- /dev/null +++ b/frontend/src/hooks/query/use-list-file.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import OpenHands from "#/api/open-hands"; +import { useAuth } from "#/context/auth-context"; + +interface UseListFileConfig { + path: string; +} + +export const useListFile = (config: UseListFileConfig) => { + const { token } = useAuth(); + + return useQuery({ + queryKey: ["file", token, config.path], + queryFn: () => OpenHands.getFile(token || "", config.path), + enabled: false, // don't fetch by default, trigger manually via `refetch` + }); +}; diff --git a/frontend/src/hooks/query/use-list-files.ts b/frontend/src/hooks/query/use-list-files.ts new file mode 100644 index 000000000000..7baa395fd7be --- /dev/null +++ b/frontend/src/hooks/query/use-list-files.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import { + useWsClient, + WsClientProviderStatus, +} from "#/context/ws-client-provider"; +import OpenHands from "#/api/open-hands"; +import { useAuth } from "#/context/auth-context"; + +interface UseListFilesConfig { + path?: string; + enabled?: boolean; +} + +export const useListFiles = (config?: UseListFilesConfig) => { + const { token } = useAuth(); + const { status } = useWsClient(); + const isActive = status === WsClientProviderStatus.ACTIVE; + + return useQuery({ + queryKey: ["files", token, config?.path], + queryFn: () => OpenHands.getFiles(token!, config?.path), + enabled: isActive && config?.enabled && !!token, + }); +}; diff --git a/frontend/src/hooks/query/use-user-repositories.ts b/frontend/src/hooks/query/use-user-repositories.ts new file mode 100644 index 000000000000..8b97d6bcd7d8 --- /dev/null +++ b/frontend/src/hooks/query/use-user-repositories.ts @@ -0,0 +1,63 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import React from "react"; +import { + isGitHubErrorReponse, + retrieveGitHubUserRepositories, +} from "#/api/github"; +import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link"; +import { useAuth } from "#/context/auth-context"; + +interface UserRepositoriesQueryFnProps { + pageParam: number; + ghToken: string; +} + +const userRepositoriesQueryFn = async ({ + pageParam, + ghToken, +}: UserRepositoriesQueryFnProps) => { + const response = await retrieveGitHubUserRepositories( + ghToken, + pageParam, + 100, + ); + + if (!response.ok) { + throw new Error("Failed to fetch repositories"); + } + + const data = (await response.json()) as GitHubRepository | GitHubErrorReponse; + + if (isGitHubErrorReponse(data)) { + throw new Error(data.message); + } + + const link = response.headers.get("link") ?? ""; + const nextPage = extractNextPageFromLink(link); + + return { data, nextPage }; +}; + +export const useUserRepositories = () => { + const { gitHubToken } = useAuth(); + + const repos = useInfiniteQuery({ + queryKey: ["repositories", gitHubToken], + queryFn: async ({ pageParam }) => + userRepositoriesQueryFn({ pageParam, ghToken: gitHubToken! }), + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage.nextPage, + enabled: !!gitHubToken, + }); + + // TODO: Once we create our custom dropdown component, we should fetch data onEndReached + // (nextui autocomplete doesn't support onEndReached nor is it compatible for extending) + const { isSuccess, isFetchingNextPage, hasNextPage, fetchNextPage } = repos; + React.useEffect(() => { + if (!isFetchingNextPage && isSuccess && hasNextPage) { + fetchNextPage(); + } + }, [isFetchingNextPage, isSuccess, hasNextPage, fetchNextPage]); + + return repos; +}; diff --git a/frontend/src/hooks/use-end-session.ts b/frontend/src/hooks/use-end-session.ts new file mode 100644 index 000000000000..602bcfa6779e --- /dev/null +++ b/frontend/src/hooks/use-end-session.ts @@ -0,0 +1,31 @@ +import { useDispatch } from "react-redux"; +import { useNavigate } from "@remix-run/react"; +import { useAuth } from "#/context/auth-context"; +import { + initialState as browserInitialState, + setScreenshotSrc, + setUrl, +} from "#/state/browserSlice"; +import { clearSelectedRepository } from "#/state/initial-query-slice"; + +export const useEndSession = () => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const { clearToken } = useAuth(); + + /** + * End the current session by clearing the token and redirecting to the home page. + */ + const endSession = () => { + clearToken(); + dispatch(clearSelectedRepository()); + + // Reset browser state to initial values + dispatch(setUrl(browserInitialState.url)); + dispatch(setScreenshotSrc(browserInitialState.screenshotSrc)); + + navigate("/"); + }; + + return endSession; +}; diff --git a/frontend/src/hooks/use-github-auth-url.ts b/frontend/src/hooks/use-github-auth-url.ts new file mode 100644 index 000000000000..e9d493764c0e --- /dev/null +++ b/frontend/src/hooks/use-github-auth-url.ts @@ -0,0 +1,20 @@ +import React from "react"; +import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url"; +import { GetConfigResponse } from "#/api/open-hands.types"; + +interface UseGitHubAuthUrlConfig { + gitHubToken: string | null; + appMode: GetConfigResponse["APP_MODE"] | null; + gitHubClientId: GetConfigResponse["GITHUB_CLIENT_ID"] | null; +} + +export const useGitHubAuthUrl = (config: UseGitHubAuthUrlConfig) => + React.useMemo(() => { + if (config.appMode === "saas" && !config.gitHubToken) + return generateGitHubAuthUrl( + config.gitHubClientId || "", + new URL(window.location.href), + ); + + return null; + }, [config.gitHubToken, config.appMode, config.gitHubClientId]); diff --git a/frontend/src/routes/_oh._index/route.tsx b/frontend/src/routes/_oh._index/route.tsx index d73ee7fd455d..10e47ba0f57a 100644 --- a/frontend/src/routes/_oh._index/route.tsx +++ b/frontend/src/routes/_oh._index/route.tsx @@ -1,81 +1,40 @@ -import { - Await, - ClientActionFunctionArgs, - ClientLoaderFunctionArgs, - defer, - redirect, - useLoaderData, - useRouteLoaderData, -} from "@remix-run/react"; +import { useLocation, useNavigate } from "@remix-run/react"; import React from "react"; import { useDispatch } from "react-redux"; -import posthog from "posthog-js"; import { SuggestionBox } from "./suggestion-box"; import { TaskForm } from "./task-form"; import { HeroHeading } from "./hero-heading"; -import { retrieveAllGitHubUserRepositories } from "#/api/github"; -import store from "#/store"; -import { - setImportedProjectZip, - setInitialQuery, -} from "#/state/initial-query-slice"; -import { clientLoader as rootClientLoader } from "#/routes/_oh"; -import OpenHands from "#/api/open-hands"; -import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url"; +import { setImportedProjectZip } from "#/state/initial-query-slice"; import { GitHubRepositoriesSuggestionBox } from "#/components/github-repositories-suggestion-box"; import { convertZipToBase64 } from "#/utils/convert-zip-to-base64"; +import { useUserRepositories } from "#/hooks/query/use-user-repositories"; +import { useGitHubUser } from "#/hooks/query/use-github-user"; +import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url"; +import { useConfig } from "#/hooks/query/use-config"; +import { useAuth } from "#/context/auth-context"; -export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => { - let isSaas = false; - let githubClientId: string | null = null; - - try { - const config = await OpenHands.getConfig(); - isSaas = config.APP_MODE === "saas"; - githubClientId = config.GITHUB_CLIENT_ID; - } catch (error) { - isSaas = false; - githubClientId = null; - } - - const ghToken = localStorage.getItem("ghToken"); - const token = localStorage.getItem("token"); - if (token) return redirect("/app"); - - let repositories: ReturnType< - typeof retrieveAllGitHubUserRepositories - > | null = null; - if (ghToken) { - const data = retrieveAllGitHubUserRepositories(ghToken); - repositories = data; - } +function Home() { + const { token, gitHubToken } = useAuth(); - let githubAuthUrl: string | null = null; - if (isSaas && githubClientId) { - const requestUrl = new URL(request.url); - githubAuthUrl = generateGitHubAuthUrl(githubClientId, requestUrl); - } + const dispatch = useDispatch(); + const location = useLocation(); + const navigate = useNavigate(); - return defer({ repositories, githubAuthUrl }); -}; + const formRef = React.useRef(null); -export const clientAction = async ({ request }: ClientActionFunctionArgs) => { - const formData = await request.formData(); - const q = formData.get("q")?.toString(); - if (q) store.dispatch(setInitialQuery(q)); + const { data: config } = useConfig(); + const { data: user } = useGitHubUser(); + const { data: repositories } = useUserRepositories(); - posthog.capture("initial_query_submitted", { - query_character_length: q?.length, + const gitHubAuthUrl = useGitHubAuthUrl({ + gitHubToken, + appMode: config?.APP_MODE || null, + gitHubClientId: config?.GITHUB_CLIENT_ID || null, }); - return redirect("/app"); -}; - -function Home() { - const dispatch = useDispatch(); - const rootData = useRouteLoaderData("routes/_oh"); - const { repositories, githubAuthUrl } = useLoaderData(); - const formRef = React.useRef(null); + React.useEffect(() => { + if (token) navigate("/app"); + }, [location.pathname]); return (
- + formRef.current?.requestSubmit()} + repositories={ + repositories?.pages.flatMap((page) => page.data) || [] } - > - - {(resolvedRepositories) => ( - formRef.current?.requestSubmit()} - repositories={resolvedRepositories} - gitHubAuthUrl={githubAuthUrl} - user={rootData?.user || null} - /> - )} - - + gitHubAuthUrl={gitHubAuthUrl} + user={user || null} + // onEndReached={} + /> ((_, ref) => { const dispatch = useDispatch(); const navigation = useNavigation(); + const navigate = useNavigate(); const { selectedRepository, files } = useSelector( (state: RootState) => state.initalQuery, @@ -51,13 +57,26 @@ export const TaskForm = React.forwardRef((_, ref) => { return "What do you want to build?"; }, [selectedRepository]); + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + + const q = formData.get("q")?.toString(); + if (q) dispatch(setInitialQuery(q)); + + posthog.capture("initial_query_submitted", { + query_character_length: q?.length, + }); + + navigate("/app"); + }; + return (
-
((_, ref) => { disabled={navigation.state === "submitting"} />
- + { const promises = uploadedFiles.map(convertImageToBase64); diff --git a/frontend/src/routes/_oh.app._index/code-editor-component.tsx b/frontend/src/routes/_oh.app._index/code-editor-component.tsx index b9f82befa43f..673010b8b1d9 100644 --- a/frontend/src/routes/_oh.app._index/code-editor-component.tsx +++ b/frontend/src/routes/_oh.app._index/code-editor-component.tsx @@ -2,10 +2,9 @@ import { Editor, EditorProps } from "@monaco-editor/react"; import React from "react"; import { useTranslation } from "react-i18next"; import { VscCode } from "react-icons/vsc"; -import toast from "react-hot-toast"; import { I18nKey } from "#/i18n/declaration"; import { useFiles } from "#/context/files"; -import OpenHands from "#/api/open-hands"; +import { useSaveFile } from "#/hooks/mutation/use-save-file"; interface CodeEditorComponentProps { onMount: EditorProps["onMount"]; @@ -25,6 +24,8 @@ function CodeEditorComponent({ saveFileContent: saveNewFileContent, } = useFiles(); + const { mutate: saveFile } = useSaveFile(); + const handleEditorChange = (value: string | undefined) => { if (selectedPath && value) modifyFileContent(selectedPath, value); }; @@ -39,11 +40,7 @@ function CodeEditorComponent({ const content = saveNewFileContent(selectedPath); if (content) { - try { - await OpenHands.saveFile(selectedPath, content); - } catch (error) { - toast.error("Failed to save file"); - } + saveFile({ path: selectedPath, content }); } } }; @@ -66,34 +63,42 @@ function CodeEditorComponent({ ); } - const fileContent = modifiedFiles[selectedPath] || files[selectedPath]; + const fileContent: string | undefined = + modifiedFiles[selectedPath] || files[selectedPath]; - if (isBase64Image(fileContent)) { - return ( -
- {selectedPath} -
- ); - } + if (fileContent) { + if (isBase64Image(fileContent)) { + return ( +
+ {selectedPath} +
+ ); + } - if (isPDF(fileContent)) { - return ( -