From 8028e2c2ddbb450e3441197c006689bf832f33ff Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Wed, 8 Jan 2025 12:36:34 -0500 Subject: [PATCH 01/14] fix: handle binary data in GoogleCloudFileStore.write (#6145) Co-authored-by: openhands --- openhands/storage/google_cloud.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openhands/storage/google_cloud.py b/openhands/storage/google_cloud.py index bbd2da273098..da763363ab3d 100644 --- a/openhands/storage/google_cloud.py +++ b/openhands/storage/google_cloud.py @@ -21,7 +21,8 @@ def __init__(self, bucket_name: Optional[str] = None) -> None: def write(self, path: str, contents: str | bytes) -> None: blob = self.bucket.blob(path) - with blob.open('w') as f: + mode = 'wb' if isinstance(contents, bytes) else 'w' + with blob.open(mode) as f: f.write(contents) def read(self, path: str) -> str: From 27d761a1fe6fe39ee4310d0b1722056a1dce6631 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 8 Jan 2025 21:57:57 +0400 Subject: [PATCH 02/14] chore(frontend): Improve conversation card (#6121) --- .../conversation-card.test.tsx | 43 ++++++------------- .../conversation-panel.test.tsx | 9 +++- .../conversation-panel/conversation-card.tsx | 35 ++++++++------- .../conversation-panel/conversation-panel.tsx | 37 ++++++++-------- 4 files changed, 59 insertions(+), 65 deletions(-) diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx index 431e6a4f9f83..abad655ae249 100644 --- a/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx +++ b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx @@ -18,8 +18,8 @@ describe("ConversationCard", () => { render( { const { rerender } = render( { rerender( { screen.getByTestId("conversation-card-selected-repository"); }); - it("should call onClick when the card is clicked", async () => { - const user = userEvent.setup(); - render( - , - ); - - const card = screen.getByTestId("conversation-card"); - await user.click(card); - - expect(onClick).toHaveBeenCalled(); - }); - it("should toggle a context menu when clicking the ellipsis button", async () => { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { it("should render the 'STOPPED' indicator by default", () => { render( { it("should render the other indicators when provided", () => { render( { const onCloseMock = vi.fn(); + const RouterStub = createRoutesStub([ + { + Component: () => , + path: "/", + }, + ]); const renderConversationPanel = (config?: QueryClientConfig) => - render(, { + render(, { wrapper: ({ children }) => ( diff --git a/frontend/src/components/features/conversation-panel/conversation-card.tsx b/frontend/src/components/features/conversation-panel/conversation-card.tsx index ba53740f805c..2e1d5f53a980 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card.tsx @@ -9,9 +9,9 @@ import { EllipsisButton } from "./ellipsis-button"; import { ConversationCardContextMenu } from "./conversation-card-context-menu"; interface ConversationCardProps { - onClick: () => void; onDelete: () => void; onChangeTitle: (title: string) => void; + isActive: boolean; title: string; selectedRepository: string | null; lastUpdatedAt: string; // ISO 8601 @@ -19,9 +19,9 @@ interface ConversationCardProps { } export function ConversationCard({ - onClick, onDelete, onChangeTitle, + isActive, title, selectedRepository, lastUpdatedAt, @@ -51,10 +51,12 @@ export function ConversationCard({ }; const handleInputClick = (event: React.MouseEvent) => { + event.preventDefault(); event.stopPropagation(); }; const handleDelete = (event: React.MouseEvent) => { + event.preventDefault(); event.stopPropagation(); onDelete(); }; @@ -74,26 +76,29 @@ export function ConversationCard({ return (
-
- +
+
+ {isActive && } + +
{ + event.preventDefault(); event.stopPropagation(); setContextMenuVisible((prev) => !prev); }} diff --git a/frontend/src/components/features/conversation-panel/conversation-panel.tsx b/frontend/src/components/features/conversation-panel/conversation-panel.tsx index 3510388ca13f..8594143c3e2d 100644 --- a/frontend/src/components/features/conversation-panel/conversation-panel.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-panel.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { useNavigate, useParams } from "react-router"; +import { NavLink, useParams } from "react-router"; import { ConversationCard } from "./conversation-card"; import { useUserConversations } from "#/hooks/query/use-user-conversations"; import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation"; @@ -16,7 +16,6 @@ interface ConversationPanelProps { export function ConversationPanel({ onClose }: ConversationPanelProps) { const { conversationId: cid } = useParams(); - const navigate = useNavigate(); const endSession = useEndSession(); const ref = useClickOutsideElement(onClose); @@ -63,11 +62,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) { }); }; - const handleClickCard = (conversationId: string) => { - navigate(`/conversations/${conversationId}`); - onClose(); - }; - return (
)} {conversations?.map((project) => ( - handleClickCard(project.conversation_id)} - onDelete={() => handleDeleteProject(project.conversation_id)} - onChangeTitle={(title) => - handleChangeTitle(project.conversation_id, project.title, title) - } - title={project.title} - selectedRepository={project.selected_repository} - lastUpdatedAt={project.last_updated_at} - status={project.status} - /> + to={`/conversations/${project.conversation_id}`} + onClick={onClose} + > + {({ isActive }) => ( + handleDeleteProject(project.conversation_id)} + onChangeTitle={(title) => + handleChangeTitle(project.conversation_id, project.title, title) + } + title={project.title} + selectedRepository={project.selected_repository} + lastUpdatedAt={project.last_updated_at} + status={project.status} + /> + )} + ))} {confirmDeleteModalVisible && ( From 27a660fb6bd8a01ef92850cc057669251ae28a4f Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Wed, 8 Jan 2025 19:20:46 +0100 Subject: [PATCH 03/14] Make runtime logs optional (#6141) --- openhands/core/logger.py | 3 +++ openhands/runtime/impl/docker/docker_runtime.py | 11 +++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/openhands/core/logger.py b/openhands/core/logger.py index 07c799d7ff90..e74a2b9decb9 100644 --- a/openhands/core/logger.py +++ b/openhands/core/logger.py @@ -20,6 +20,9 @@ LOG_ALL_EVENTS = os.getenv('LOG_ALL_EVENTS', 'False').lower() in ['true', '1', 'yes'] +# Controls whether to stream Docker container logs +DEBUG_RUNTIME = os.getenv('DEBUG_RUNTIME', 'False').lower() in ['true', '1', 'yes'] + ColorType = Literal[ 'red', 'green', diff --git a/openhands/runtime/impl/docker/docker_runtime.py b/openhands/runtime/impl/docker/docker_runtime.py index b56d866fcb81..931d3254ea5f 100644 --- a/openhands/runtime/impl/docker/docker_runtime.py +++ b/openhands/runtime/impl/docker/docker_runtime.py @@ -10,9 +10,8 @@ from openhands.core.exceptions import ( AgentRuntimeDisconnectedError, AgentRuntimeNotFoundError, - AgentRuntimeNotReadyError, ) -from openhands.core.logger import DEBUG +from openhands.core.logger import DEBUG, DEBUG_RUNTIME from openhands.core.logger import openhands_logger as logger from openhands.events import EventStream from openhands.runtime.builder import DockerRuntimeBuilder @@ -139,7 +138,10 @@ async def connect(self): f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}', ) - self.log_streamer = LogStreamer(self.container, self.log) + if DEBUG_RUNTIME: + self.log_streamer = LogStreamer(self.container, self.log) + else: + self.log_streamer = None if not self.attach_to_existing: self.log('info', f'Waiting for client to become ready at {self.api_url}...') @@ -331,9 +333,6 @@ def _wait_until_alive(self): f'Container {self.container_name} not found.' ) - if not self.log_streamer: - raise AgentRuntimeNotReadyError('Runtime client is not ready.') - self.check_if_alive() def close(self, rm_all_containers: bool | None = None): From e308b6fb6fd6eaca20360e2634eaca6b4d791f43 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 8 Jan 2025 22:30:29 +0400 Subject: [PATCH 04/14] chore(backend): Update default conversation title logic (#6138) --- openhands/server/routes/manage_conversations.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 178fbefe66c0..8b788e23c99e 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -66,9 +66,10 @@ async def new_conversation(request: Request, data: InitSessionRequest): conversation_id = uuid.uuid4().hex logger.info(f'New conversation ID: {conversation_id}') - conversation_title = ( - data.selected_repository or f'Conversation {conversation_id[:5]}' + repository_title = ( + data.selected_repository.split('/')[-1] if data.selected_repository else None ) + conversation_title = f'{repository_title or "Conversation"} {conversation_id[:5]}' logger.info(f'Saving metadata for conversation {conversation_id}') await conversation_store.save_metadata( From 62c4bab6ba5fcb30cd409af5957367638f7c0484 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 8 Jan 2025 23:53:24 +0400 Subject: [PATCH 05/14] hotfix(frontend): Prevent a redirect when clicking edit (#6151) --- .../components/features/conversation-panel/conversation-card.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/features/conversation-panel/conversation-card.tsx b/frontend/src/components/features/conversation-panel/conversation-card.tsx index 2e1d5f53a980..e7c87e061554 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card.tsx @@ -62,6 +62,7 @@ export function ConversationCard({ }; const handleEdit = (event: React.MouseEvent) => { + event.preventDefault(); event.stopPropagation(); setTitleMode("edit"); setContextMenuVisible(false); From 386e04a2baace66619f2eaf6208a26f3c6e00994 Mon Sep 17 00:00:00 2001 From: ross <151584650+ross-rl@users.noreply.github.com> Date: Wed, 8 Jan 2025 12:18:24 -0800 Subject: [PATCH 06/14] Fix field deprecation in runloop runtime client (#6152) --- openhands/runtime/impl/runloop/runloop_runtime.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openhands/runtime/impl/runloop/runloop_runtime.py b/openhands/runtime/impl/runloop/runloop_runtime.py index 2e51ea409323..93f019561ff0 100644 --- a/openhands/runtime/impl/runloop/runloop_runtime.py +++ b/openhands/runtime/impl/runloop/runloop_runtime.py @@ -115,13 +115,15 @@ def _create_new_devbox(self) -> DevboxView: devbox = self.runloop_api_client.devboxes.create( entrypoint=entrypoint, - setup_commands=[f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'], name=self.sid, environment_variables={'DEBUG': 'true'} if self.config.debug else {}, prebuilt='openhands', launch_parameters=LaunchParameters( available_ports=[self._sandbox_port, self._vscode_port], resource_size_request='LARGE', + launch_commands=[ + f'mkdir -p {self.config.workspace_mount_path_in_sandbox}' + ], ), metadata={'container-name': self.container_name}, ) From c411a29db408564cf1935e0fe0165e2b4362b270 Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Wed, 8 Jan 2025 16:12:46 -0500 Subject: [PATCH 07/14] Move GitHub Token export to backend (#6153) Co-authored-by: openhands --- .../hooks/use-handle-runtime-active.ts | 22 +------------------ frontend/src/services/terminal-service.ts | 6 ----- .../runtime/impl/remote/remote_runtime.py | 3 ++- openhands/server/session/agent_session.py | 8 +++++++ 4 files changed, 11 insertions(+), 28 deletions(-) diff --git a/frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts b/frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts index 1165127e2497..246d3794ad3a 100644 --- a/frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts +++ b/frontend/src/routes/_oh.app/hooks/use-handle-runtime-active.ts @@ -1,24 +1,16 @@ import React from "react"; import toast from "react-hot-toast"; import { useDispatch, useSelector } from "react-redux"; -import { useAuth } from "#/context/auth-context"; -import { useWsClient } from "#/context/ws-client-provider"; -import { getGitHubTokenCommand } from "#/services/terminal-service"; import { setImportedProjectZip } from "#/state/initial-query-slice"; import { RootState } from "#/store"; import { base64ToBlob } from "#/utils/base64-to-blob"; import { useUploadFiles } from "../../../hooks/mutation/use-upload-files"; -import { useGitHubUser } from "../../../hooks/query/use-github-user"; -import { isGitHubErrorReponse } from "#/api/github-axios-instance"; + import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; export const useHandleRuntimeActive = () => { - const { gitHubToken } = useAuth(); - const { send } = useWsClient(); - const dispatch = useDispatch(); - const { data: user } = useGitHubUser(); const { mutate: uploadFiles } = useUploadFiles(); const { curAgentState } = useSelector((state: RootState) => state.agent); @@ -28,11 +20,6 @@ export const useHandleRuntimeActive = () => { (state: RootState) => state.initialQuery, ); - const userId = React.useMemo(() => { - if (user && !isGitHubErrorReponse(user)) return user.id; - return null; - }, [user]); - const handleUploadFiles = (zip: string) => { const blob = base64ToBlob(zip); const file = new File([blob], "imported-project.zip", { @@ -49,13 +36,6 @@ export const useHandleRuntimeActive = () => { dispatch(setImportedProjectZip(null)); }; - React.useEffect(() => { - if (runtimeActive && userId && gitHubToken) { - // Export if the user valid, this could happen mid-session so it is handled here - send(getGitHubTokenCommand(gitHubToken)); - } - }, [userId, gitHubToken, runtimeActive]); - React.useEffect(() => { if (runtimeActive && importedProjectZip) { handleUploadFiles(importedProjectZip); diff --git a/frontend/src/services/terminal-service.ts b/frontend/src/services/terminal-service.ts index c5807596b65d..9ef1cdf151cb 100644 --- a/frontend/src/services/terminal-service.ts +++ b/frontend/src/services/terminal-service.ts @@ -4,9 +4,3 @@ export function getTerminalCommand(command: string, hidden: boolean = false) { const event = { action: ActionType.RUN, args: { command, hidden } }; return event; } - -export function getGitHubTokenCommand(gitHubToken: string) { - const command = `export GITHUB_TOKEN=${gitHubToken}`; - const event = getTerminalCommand(command, true); - return event; -} diff --git a/openhands/runtime/impl/remote/remote_runtime.py b/openhands/runtime/impl/remote/remote_runtime.py index cc2fdbf9391d..cc9fae9e88fa 100644 --- a/openhands/runtime/impl/remote/remote_runtime.py +++ b/openhands/runtime/impl/remote/remote_runtime.py @@ -249,6 +249,8 @@ def _resume_runtime(self): timeout=60, ): pass + self._wait_until_alive() + self.setup_initial_env() self.log('debug', 'Runtime resumed.') def _parse_runtime_response(self, response: requests.Response): @@ -388,7 +390,6 @@ def _send_action_server_request(self, method, url, **kwargs): elif e.response.status_code == 503: self.log('warning', 'Runtime appears to be paused. Resuming...') self._resume_runtime() - self._wait_until_alive() return super()._send_action_server_request(method, url, **kwargs) else: raise e diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index b8a440d32f75..f7ffbaecad50 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -180,6 +180,13 @@ async def _create_runtime( logger.debug(f'Initializing runtime `{runtime_name}` now...') runtime_cls = get_runtime_cls(runtime_name) + env_vars = ( + { + 'GITHUB_TOKEN': github_token, + } + if github_token + else None + ) self.runtime = runtime_cls( config=config, event_stream=self.event_stream, @@ -187,6 +194,7 @@ async def _create_runtime( plugins=agent.sandbox_plugins, status_callback=self._status_callback, headless_mode=False, + env_vars=env_vars, ) # FIXME: this sleep is a terrible hack. From 5458ebbd7d1b1f5c16c221bdbd0e4aa934b39cf6 Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Thu, 9 Jan 2025 11:39:53 +0900 Subject: [PATCH 08/14] Fix issue #6048: Update documentation of recommended models and add deepseek (#6050) Co-authored-by: openhands Co-authored-by: Engel Nyst --- docs/modules/usage/llms/llms.md | 19 +++++-------------- frontend/src/utils/verified-models.ts | 9 +++++++-- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/docs/modules/usage/llms/llms.md b/docs/modules/usage/llms/llms.md index 709e86c3cf9a..5e6a472d0c0a 100644 --- a/docs/modules/usage/llms/llms.md +++ b/docs/modules/usage/llms/llms.md @@ -5,23 +5,14 @@ OpenHands can connect to any LLM supported by LiteLLM. However, it requires a po ## Model Recommendations Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some -recommendations for model selection. Some analyses can be found in [this blog article comparing LLMs](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) and -[this blog article with some more recent results](https://www.all-hands.dev/blog/openhands-codeact-21-an-open-state-of-the-art-software-development-agent). - -When choosing a model, consider both the quality of outputs and the associated costs. Here's a summary of the findings: - -- Claude 3.5 Sonnet is the best by a fair amount, achieving a 53% resolve rate on SWE-Bench Verified with the default agent in OpenHands. -- GPT-4o lags behind, and o1-mini actually performed somewhat worse than GPT-4o. We went in and analyzed the results a little, and briefly it seemed like o1 was sometimes "overthinking" things, performing extra environment configuration tasks when it could just go ahead and finish the task. -- Finally, the strongest open models were Llama 3.1 405 B and deepseek-v2.5, and they performed reasonably, even besting some of the closed models. - -Please refer to the [full article](https://www.all-hands.dev/blog/evaluation-of-llms-as-coding-agents-on-swe-bench-at-30x-speed) for more details. +recommendations for model selection. Our latest benchmarking results can be found in [this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0). Based on these findings and community feedback, the following models have been verified to work reasonably well with OpenHands: -- claude-3-5-sonnet (recommended) -- gpt-4 / gpt-4o -- llama-3.1-405b -- deepseek-v2.5 +- anthropic/claude-3-5-sonnet-20241022 (recommended) +- anthropic/claude-3-5-haiku-20241022 +- deepseek/deepseek-chat +- gpt-4o :::warning OpenHands will issue many prompts to the LLM you configure. Most of these LLMs cost money, so be sure to set spending diff --git a/frontend/src/utils/verified-models.ts b/frontend/src/utils/verified-models.ts index 885bd7ac7e8f..da77b25e1c7a 100644 --- a/frontend/src/utils/verified-models.ts +++ b/frontend/src/utils/verified-models.ts @@ -1,6 +1,10 @@ // Here are the list of verified models and providers that we know work well with OpenHands. -export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic"]; -export const VERIFIED_MODELS = ["gpt-4o", "claude-3-5-sonnet-20241022"]; +export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic", "deepseek"]; +export const VERIFIED_MODELS = [ + "gpt-4o", + "claude-3-5-sonnet-20241022", + "deepseek-chat", +]; // LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency // (e.g., they return `gpt-4o` instead of `openai/gpt-4o`) @@ -21,6 +25,7 @@ export const VERIFIED_ANTHROPIC_MODELS = [ "claude-2.1", "claude-3-5-sonnet-20240620", "claude-3-5-sonnet-20241022", + "claude-3-5-haiku-20241022", "claude-3-haiku-20240307", "claude-3-opus-20240229", "claude-3-sonnet-20240229", From 0d409c8c244931d66980735a514cee77fc65ff64 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Thu, 9 Jan 2025 17:43:39 +0400 Subject: [PATCH 09/14] fix(frontend): Prevent saving empty custom model (#6149) --- .../modals/settings/settings-form.test.tsx | 51 +++++++++++++++---- .../shared/inputs/advanced-option-switch.tsx | 1 + .../shared/inputs/custom-model-input.tsx | 2 + .../modals/settings/runtime-size-selector.tsx | 1 + .../shared/modals/settings/settings-form.tsx | 1 + 5 files changed, 46 insertions(+), 10 deletions(-) diff --git a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx index e373fdfb3e4f..06d1628e1f74 100644 --- a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx +++ b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx @@ -1,13 +1,22 @@ import { screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { renderWithProviders } from "test-utils"; import { createRoutesStub } from "react-router"; +import userEvent from "@testing-library/user-event"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { SettingsForm } from "#/components/shared/modals/settings/settings-form"; import OpenHands from "#/api/open-hands"; describe("SettingsForm", () => { const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); + + const onCloseMock = vi.fn(); + + afterEach(() => { + vi.clearAllMocks(); + }); + getConfigSpy.mockResolvedValue({ APP_MODE: "saas", GITHUB_CLIENT_ID: "123", @@ -19,10 +28,10 @@ describe("SettingsForm", () => { Component: () => ( {}} + models={["anthropic/claude-3-5-sonnet-20241022", "model2"]} + agents={["CodeActAgent", "agent2"]} + securityAnalyzers={["analyzer1", "analyzer2"]} + onClose={onCloseMock} /> ), path: "/", @@ -35,11 +44,33 @@ describe("SettingsForm", () => { }); it("should show runtime size selector when advanced options are enabled", async () => { + const user = userEvent.setup(); renderWithProviders(); - const advancedSwitch = screen.getByRole("switch", { - name: "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL", - }); - fireEvent.click(advancedSwitch); - await screen.findByText("SETTINGS_FORM$RUNTIME_SIZE_LABEL"); + + const toggleAdvancedMode = screen.getByTestId("advanced-option-switch"); + await user.click(toggleAdvancedMode); + + await screen.findByTestId("runtime-size"); + }); + + it("should not submit the form if required fields are empty", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + expect(screen.queryByTestId("custom-model-input")).not.toBeInTheDocument(); + + const toggleAdvancedMode = screen.getByTestId("advanced-option-switch"); + await user.click(toggleAdvancedMode); + + const customModelInput = screen.getByTestId("custom-model-input"); + expect(customModelInput).toBeInTheDocument(); + + await user.clear(customModelInput); + + const saveButton = screen.getByTestId("save-settings-button"); + await user.click(saveButton); + + expect(saveSettingsSpy).not.toHaveBeenCalled(); + expect(onCloseMock).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/shared/inputs/advanced-option-switch.tsx b/frontend/src/components/shared/inputs/advanced-option-switch.tsx index 50709f9de3ac..958dc9f1bf46 100644 --- a/frontend/src/components/shared/inputs/advanced-option-switch.tsx +++ b/frontend/src/components/shared/inputs/advanced-option-switch.tsx @@ -18,6 +18,7 @@ export function AdvancedOptionSwitch({ return ( + + ); +} diff --git a/frontend/src/components/layout/served-app-label.tsx b/frontend/src/components/layout/served-app-label.tsx new file mode 100644 index 000000000000..824b3f3608f7 --- /dev/null +++ b/frontend/src/components/layout/served-app-label.tsx @@ -0,0 +1,12 @@ +import { useActiveHost } from "#/hooks/query/use-active-host"; + +export function ServedAppLabel() { + const { activeHost } = useActiveHost(); + + return ( +
+
App
+ {activeHost &&
} +
+ ); +} diff --git a/frontend/src/entry.client.tsx b/frontend/src/entry.client.tsx index 125833618e34..ba17326b4699 100644 --- a/frontend/src/entry.client.tsx +++ b/frontend/src/entry.client.tsx @@ -50,10 +50,13 @@ async function prepareApp() { } } +const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"]; const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { - if (!query.queryKey.includes("authenticated")) toast.error(error.message); + if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) { + toast.error(error.message); + } }, }), defaultOptions: { diff --git a/frontend/src/hooks/query/use-active-host.ts b/frontend/src/hooks/query/use-active-host.ts new file mode 100644 index 000000000000..6a5f8ec017dc --- /dev/null +++ b/frontend/src/hooks/query/use-active-host.ts @@ -0,0 +1,51 @@ +import { useQueries, useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import React from "react"; +import { useSelector } from "react-redux"; +import { openHands } from "#/api/open-hands-axios"; +import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; +import { RootState } from "#/store"; +import { useConversation } from "#/context/conversation-context"; + +export const useActiveHost = () => { + const { curAgentState } = useSelector((state: RootState) => state.agent); + const [activeHost, setActiveHost] = React.useState(null); + + const { conversationId } = useConversation(); + + const { data } = useQuery({ + queryKey: [conversationId, "hosts"], + queryFn: async () => { + const response = await openHands.get<{ hosts: string[] }>( + `/api/conversations/${conversationId}/web-hosts`, + ); + return { hosts: Object.keys(response.data.hosts) }; + }, + enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState), + initialData: { hosts: [] }, + }); + + const apps = useQueries({ + queries: data.hosts.map((host) => ({ + queryKey: [conversationId, "hosts", host], + queryFn: async () => { + try { + await axios.get(host); + return host; + } catch (e) { + return ""; + } + }, + refetchInterval: 3000, + })), + }); + + const appsData = apps.map((app) => app.data); + + React.useEffect(() => { + const successfulApp = appsData.find((app) => app); + setActiveHost(successfulApp || ""); + }, [appsData]); + + return { activeHost }; +}; diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 75c88ff78a7c..53305537a0b8 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -12,6 +12,7 @@ export default [ index("routes/_oh.app._index/route.tsx"), route("browser", "routes/_oh.app.browser.tsx"), route("jupyter", "routes/_oh.app.jupyter.tsx"), + route("served", "routes/app.tsx"), ]), ]), diff --git a/frontend/src/routes/_oh.app/route.tsx b/frontend/src/routes/_oh.app/route.tsx index 086d3f0f5e23..182a7a98e57c 100644 --- a/frontend/src/routes/_oh.app/route.tsx +++ b/frontend/src/routes/_oh.app/route.tsx @@ -2,6 +2,7 @@ import { useDisclosure } from "@nextui-org/react"; import React from "react"; import { Outlet } from "react-router"; import { useDispatch, useSelector } from "react-redux"; +import { FaServer } from "react-icons/fa"; import toast from "react-hot-toast"; import { ConversationProvider, @@ -31,6 +32,7 @@ import Security from "#/components/shared/modals/security/security"; import { useEndSession } from "#/hooks/use-end-session"; import { useUserConversation } from "#/hooks/query/use-user-conversation"; import { CountBadge } from "#/components/layout/count-badge"; +import { ServedAppLabel } from "#/components/layout/served-app-label"; import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label"; import { useSettings } from "#/hooks/query/use-settings"; import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags"; @@ -126,6 +128,11 @@ function AppContent() { labels={[ { label: "Workspace", to: "", icon: }, { label: "Jupyter", to: "jupyter", icon: }, + { + label: , + to: "served", + icon: , + }, { label: (
diff --git a/frontend/src/routes/app.tsx b/frontend/src/routes/app.tsx new file mode 100644 index 000000000000..e44ac6947d36 --- /dev/null +++ b/frontend/src/routes/app.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { FaArrowRotateRight } from "react-icons/fa6"; +import { FaExternalLinkAlt, FaHome } from "react-icons/fa"; +import { useActiveHost } from "#/hooks/query/use-active-host"; +import { PathForm } from "#/components/features/served-host/path-form"; + +function ServedApp() { + const { activeHost } = useActiveHost(); + const [refreshKey, setRefreshKey] = React.useState(0); + const [currentActiveHost, setCurrentActiveHost] = React.useState< + string | null + >(null); + const [path, setPath] = React.useState("hello"); + + const formRef = React.useRef(null); + + const handleOnBlur = () => { + if (formRef.current) { + const formData = new FormData(formRef.current); + const urlInputValue = formData.get("url")?.toString(); + + if (urlInputValue) { + const url = new URL(urlInputValue); + + setCurrentActiveHost(url.origin); + setPath(url.pathname); + } + } + }; + + const resetUrl = () => { + setCurrentActiveHost(activeHost); + setPath(""); + + if (formRef.current) { + formRef.current.reset(); + } + }; + + React.useEffect(() => { + resetUrl(); + }, [activeHost]); + + const fullUrl = `${currentActiveHost}/${path}`; + + if (!currentActiveHost) { + return ( +
+ + If you tell OpenHands to start a web server, the app will appear here. + +
+ ); + } + + return ( +
+
+ + + + +
+ +
+
+