Compare commits

..

15 Commits

Author SHA1 Message Date
openhands
80f1da66f1 Improve git changes fix with better error handling and reduced cache time
- Fix _get_default_branch() to handle repositories without remote origin
- Reduce frontend cache stale time from 5 minutes to 30 seconds for fresher data
- Add fallback to 'main' branch when remote origin is not available
- Improve robustness for edge cases like local-only repositories
2025-07-22 15:43:14 +00:00
openhands
50cd559cd4 Fix git changes showing merged files as user changes
This commit fixes an issue where the changes tab would show all merged files
from the main branch as if they were user-created changes after performing
a merge operation.

The problem occurred when:
1. User creates a branch that is NOT up-to-date with main
2. User asks OpenHands to fetch and merge latest changes from main
3. The changes tab would show all merged files as 'Added' changes
4. Only a refresh would fix the display

Root cause:
The _get_valid_ref() method was always preferring the remote tracking branch
(origin/feature-branch) as the comparison base. After a merge, this remote
branch still pointed to the old commit before the merge, causing git diff
to show all merged changes as if they were new user changes.

Solution:
- Added _has_diverged_from_remote_tracking_branch() method to detect when
  the local branch has diverged from its remote tracking branch due to merges
- Modified _get_valid_ref() to use merge-base with the default branch when
  divergence is detected, preventing merged changes from appearing as user changes
- Added comprehensive tests to verify the fix works correctly

The fix ensures that only actual user changes are shown in the changes tab,
while merged changes from the main branch are properly excluded.
2025-07-22 15:16:48 +00:00
llamantino
dc2f5cd1b0 fix(cli): filter out LiteLLM coroutine not awaited warning at shutdown (#9842) 2025-07-22 21:53:58 +08:00
mamoodi
07041e057d fix(frontend): Add context menu state management to Controls component (#9841)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-22 09:49:41 -04:00
mamoodi
6e91d19f80 Fix: Prevent LLM settings from being accessible in SaaS mode via double-click (#9831)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-22 09:49:31 -04:00
dependabot[bot]
936510e219 chore(deps): bump the version-all group in /frontend with 2 updates (#9829)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 17:41:02 +04:00
Boxuan Li
7af35ab827 Evaluation: disable browser when NOT run_with_browsing (#9837) 2025-07-22 01:45:52 +00:00
Xingyao Wang
a7245f2de2 fix(CLI): alias persistence issue (#9828)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-22 05:45:14 +08:00
Tim O'Farrell
6d7ab8a022 Fix for issue where some cases use WORK_PORT and some use APP_PORT (#9830) 2025-07-21 20:24:24 +00:00
Hiep Le
bbfa37fd97 feat(frontend): Allow searching/filtering repositories. (#9791) 2025-07-21 16:05:32 +00:00
dependabot[bot]
d0cf12e474 chore(deps-dev): bump the eslint group in /frontend with 3 updates (#9825)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-21 16:02:35 +00:00
sp.wack
78306b1ee7 hotfix(frontend): Fix context menu closing (#9822) 2025-07-21 19:44:08 +04:00
sp.wack
f6d99234f1 fix(frontend): Fix auth modal tests by adding required providersConfigured prop (#9823) 2025-07-21 19:40:54 +04:00
Boxuan Li
19ca52f954 Skip browser dependency build in Dockerfile when browser is disabled (#9815) 2025-07-21 08:34:11 -07:00
Hiep Le
df75116184 feat(frontend): Integrate with API to display repositories and their associated microagents. (#9784)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-07-21 19:19:34 +04:00
67 changed files with 2022 additions and 753 deletions

View File

@@ -117,6 +117,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -345,6 +345,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -226,6 +226,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -203,6 +203,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -164,6 +164,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -19,7 +19,13 @@ describe("AuthModal", () => {
});
it("should render the GitHub and GitLab buttons", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
render(
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
providersConfigured={["github", "gitlab"]}
/>,
);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
@@ -35,7 +41,13 @@ describe("AuthModal", () => {
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
const user = userEvent.setup();
const mockUrl = "https://github.com/login/oauth/authorize";
render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
render(
<AuthModal
githubAuthUrl={mockUrl}
appMode="saas"
providersConfigured={["github"]}
/>,
);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
@@ -52,7 +64,6 @@ describe("AuthModal", () => {
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
expect(termsSection).toBeInTheDocument();
// Check that all text content is present in the paragraph
expect(termsSection).toHaveTextContent(
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",

View File

@@ -16,8 +16,6 @@ import { ConversationCard } from "#/components/features/conversation-panel/conve
import { clickOnEditButton } from "./utils";
// We'll use the actual i18next implementation but override the translation function
import { I18nextProvider } from "react-i18next";
import i18n from "i18next";
// Mock the t function to return our custom translations
vi.mock("react-i18next", async () => {
@@ -124,7 +122,8 @@ describe("ConversationCard", () => {
it("should toggle a context menu when clicking the ellipsis button", async () => {
const user = userEvent.setup();
renderWithProviders(
const onContextMenuToggle = vi.fn();
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
@@ -132,6 +131,8 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen={false}
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -140,15 +141,32 @@ describe("ConversationCard", () => {
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
expect(onContextMenuToggle).toHaveBeenCalledWith(true);
// Simulate context menu being opened by parent
rerender(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
screen.getByTestId("context-menu");
await user.click(ellipsisButton);
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
});
it("should call onDelete when the delete button is clicked", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -157,18 +175,18 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
await user.click(deleteButton);
expect(onDelete).toHaveBeenCalled();
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
});
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
@@ -198,7 +216,11 @@ describe("ConversationCard", () => {
test("conversation title should call onChangeTitle when changed and blurred", async () => {
const user = userEvent.setup();
renderWithProviders(
let menuOpen = true;
const onContextMenuToggle = vi.fn((isOpen: boolean) => {
menuOpen = isOpen;
});
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -206,10 +228,27 @@ describe("ConversationCard", () => {
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
contextMenuOpen={menuOpen}
onContextMenuToggle={onContextMenuToggle}
/>,
);
await clickOnEditButton(user);
// Re-render with updated state
rerender(
<ConversationCard
onDelete={onDelete}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
contextMenuOpen={menuOpen}
onContextMenuToggle={onContextMenuToggle}
/>,
);
const title = screen.getByTestId("conversation-card-title");
expect(title).toBeEnabled();
@@ -227,6 +266,7 @@ describe("ConversationCard", () => {
it("should reset title and not call onChangeTitle when the title is empty", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -235,6 +275,8 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -271,6 +313,7 @@ describe("ConversationCard", () => {
test("clicking the title should not trigger the onClick handler if edit mode", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -279,6 +322,8 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -292,6 +337,7 @@ describe("ConversationCard", () => {
test("clicking the delete button should not trigger the onClick handler", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -300,12 +346,11 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
@@ -315,7 +360,7 @@ describe("ConversationCard", () => {
});
it("should show display cost button only when showOptions is true", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -324,21 +369,17 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Wait for context menu to appear
const menu = await screen.findByTestId("context-menu");
expect(
within(menu).queryByTestId("display-cost-button"),
).not.toBeInTheDocument();
// Close menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onDelete={onDelete}
@@ -348,12 +389,11 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
// Open menu again
await user.click(ellipsisButton);
// Wait for context menu to appear and check for display cost button
const newMenu = await screen.findByTestId("context-menu");
within(newMenu).getByTestId("display-cost-button");
@@ -361,6 +401,7 @@ describe("ConversationCard", () => {
it("should show metrics modal when clicking the display cost button", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -370,12 +411,11 @@ describe("ConversationCard", () => {
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
showOptions
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const displayCostButton = within(menu).getByTestId("display-cost-button");
@@ -386,7 +426,7 @@ describe("ConversationCard", () => {
});
it("should not display the edit or delete options if the handler is not provided", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
const { rerender } = renderWithProviders(
<ConversationCard
onClick={onClick}
@@ -394,19 +434,15 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = await screen.findByTestId("context-menu");
expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument();
expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument();
// toggle to hide the context menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onClick={onClick}
@@ -414,10 +450,11 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
await user.click(ellipsisButton);
const newMenu = await screen.findByTestId("context-menu");
expect(
within(newMenu).queryByTestId("edit-button"),

View File

@@ -0,0 +1,752 @@
import { screen, waitFor, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientConfig } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import React from "react";
import { renderWithProviders } from "test-utils";
import MicroagentManagement from "#/routes/microagent-management";
import OpenHands from "#/api/open-hands";
import { GitRepository } from "#/types/git";
import { RepositoryMicroagent } from "#/types/microagent-management";
describe("MicroagentManagement", () => {
const RouterStub = createRoutesStub([
{
Component: MicroagentManagement,
path: "/",
},
]);
const renderMicroagentManagement = (config?: QueryClientConfig) =>
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagent: null,
addMicroagentModalVisible: false,
selectedRepository: null,
personalRepositories: [],
organizationRepositories: [],
repositories: [],
},
},
});
beforeAll(() => {
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
Link: ({ children }: React.PropsWithChildren) => children,
useNavigate: vi.fn(() => vi.fn()),
useLocation: vi.fn(() => ({ pathname: "/microagent-management" })),
useParams: vi.fn(() => ({ conversationId: "2" })),
}));
});
const mockRepositories: GitRepository[] = [
{
id: "1",
full_name: "user/repo1",
git_provider: "github",
is_public: true,
owner_type: "user",
pushed_at: "2021-10-01T12:00:00Z",
},
{
id: "2",
full_name: "user/repo2/.openhands",
git_provider: "github",
is_public: true,
owner_type: "user",
pushed_at: "2021-10-02T12:00:00Z",
},
{
id: "3",
full_name: "org/repo3/.openhands",
git_provider: "github",
is_public: true,
owner_type: "organization",
pushed_at: "2021-10-03T12:00:00Z",
},
{
id: "4",
full_name: "user/repo4",
git_provider: "github",
is_public: true,
owner_type: "user",
pushed_at: "2021-10-04T12:00:00Z",
},
{
id: "5",
full_name: "user/TestRepository",
git_provider: "github",
is_public: true,
owner_type: "user",
pushed_at: "2021-10-05T12:00:00Z",
},
{
id: "6",
full_name: "org/AnotherRepo",
git_provider: "github",
is_public: true,
owner_type: "organization",
pushed_at: "2021-10-06T12:00:00Z",
},
];
const mockMicroagents: RepositoryMicroagent[] = [
{
name: "test-microagent-1",
type: "repo",
content: "Test microagent content 1",
triggers: ["test", "microagent"],
inputs: [],
tools: [],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
},
{
name: "test-microagent-2",
type: "knowledge",
content: "Test microagent content 2",
triggers: ["knowledge", "test"],
inputs: [],
tools: [],
created_at: "2021-10-02T12:00:00Z",
git_provider: "github",
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
// Setup default mock for retrieveUserGitRepositories
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue([
...mockRepositories,
]);
// Setup default mock for getRepositoryMicroagents
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
...mockMicroagents,
]);
});
it("should render the microagent management page", async () => {
renderMicroagentManagement();
// Check that the main title is rendered
await screen.findByText("MICROAGENT_MANAGEMENT$DESCRIPTION");
});
it("should display loading state when fetching repositories", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockImplementation(
() => new Promise(() => {}), // Never resolves
);
renderMicroagentManagement();
// Check that loading spinner is displayed
const loadingSpinner = await screen.findByText("HOME$LOADING_REPOSITORIES");
expect(loadingSpinner).toBeInTheDocument();
});
it("should handle error when fetching repositories", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockRejectedValue(
new Error("Failed to fetch repositories"),
);
renderMicroagentManagement();
// Wait for the error to be handled
await waitFor(() => {
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
});
});
it("should categorize repositories correctly", async () => {
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Check that tabs are rendered
const personalTab = screen.getByText("COMMON$PERSONAL");
const repositoriesTab = screen.getByText("COMMON$REPOSITORIES");
const organizationsTab = screen.getByText("COMMON$ORGANIZATIONS");
expect(personalTab).toBeInTheDocument();
expect(repositoriesTab).toBeInTheDocument();
expect(organizationsTab).toBeInTheDocument();
});
it("should display repositories in accordion", async () => {
renderMicroagentManagement();
// Wait for repositories to be loaded and rendered
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Check that repository names are displayed
const repo1 = screen.getByText("user/repo2/.openhands");
expect(repo1).toBeInTheDocument();
});
it("should expand repository accordion and show microagents", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for microagents to be fetched
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
});
// Check that microagents are displayed
const microagent1 = screen.getByText("test-microagent-1");
const microagent2 = screen.getByText("test-microagent-2");
expect(microagent1).toBeInTheDocument();
expect(microagent2).toBeInTheDocument();
});
it("should display loading state when fetching microagents", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
getRepositoryMicroagentsSpy.mockImplementation(
() => new Promise(() => {}), // Never resolves
);
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Check that loading spinner is displayed
const loadingSpinner = screen.getByTestId("loading-spinner");
expect(loadingSpinner).toBeInTheDocument();
});
it("should handle error when fetching microagents", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
getRepositoryMicroagentsSpy.mockRejectedValue(
new Error("Failed to fetch microagents"),
);
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for the error to be handled
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalledWith("user", "repo2");
});
});
it("should display empty state when no microagents are found", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
getRepositoryMicroagentsSpy.mockResolvedValue([]);
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for microagents to be fetched
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalledWith("user", "repo2");
});
// Check that the learn this repo component is displayed
const learnThisRepo = screen.getByText(
"MICROAGENT_MANAGEMENT$LEARN_THIS_REPO",
);
expect(learnThisRepo).toBeInTheDocument();
});
it("should display microagent cards with correct information", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for microagents to be fetched
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
});
// Check that microagent cards display correct information
const microagent1 = screen.getByText("test-microagent-1");
const microagent2 = screen.getByText("test-microagent-2");
expect(microagent1).toBeInTheDocument();
expect(microagent2).toBeInTheDocument();
// Check that microagent file paths are displayed
const filePath1 = screen.getByText(
".openhands/microagents/test-microagent-1",
);
const filePath2 = screen.getByText(
".openhands/microagents/test-microagent-2",
);
expect(filePath1).toBeInTheDocument();
expect(filePath2).toBeInTheDocument();
});
it("should display add microagent button in repository accordion", async () => {
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Check that add microagent buttons are present
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
expect(addButtons.length).toBeGreaterThan(0);
});
it("should open add microagent modal when add button is clicked", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
await user.click(addButtons[0]);
// Check that the modal is opened
const modalTitle = screen.getByText(
"MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT",
);
expect(modalTitle).toBeInTheDocument();
});
it("should close add microagent modal when cancel is clicked", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
await user.click(addButtons[0]);
// Check that the modal is opened
const modalTitle = screen.getByText(
"MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT",
);
expect(modalTitle).toBeInTheDocument();
// Find and click the cancel button
const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton);
// Check that the modal is closed
expect(
screen.queryByText("MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT"),
).not.toBeInTheDocument();
});
it("should display empty state when no repositories are found", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue([]);
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
});
// Check that empty state messages are displayed
const personalEmptyState = screen.getByText(
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS",
);
expect(personalEmptyState).toBeInTheDocument();
});
it("should handle multiple repository expansions", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
const repoAccordion1 = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion1);
// Wait for microagents to be fetched for first repo
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
});
// Check that the API call was made
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledTimes(1);
});
it("should display ready to add microagent message in main area", async () => {
renderMicroagentManagement();
// Check that the main area shows the ready message
const readyMessage = screen.getByText(
"MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT",
);
const descriptionMessage = screen.getByText(
"MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
);
expect(readyMessage).toBeInTheDocument();
expect(descriptionMessage).toBeInTheDocument();
});
// Search functionality tests
describe("Search functionality", () => {
it("should render search input field", async () => {
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Check that search input is rendered
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveAttribute(
"placeholder",
"Search repositories...",
);
});
it("should filter repositories when typing in search input", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Initially only repositories with .openhands should be visible
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
// Type in search input to filter further
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "repo2");
// Only repo2 should be visible
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
});
it("should perform case-insensitive search", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with uppercase
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "REPO2");
// repo2 should be visible (case-insensitive match)
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
});
it("should filter repositories by partial matches", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with partial match
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "repo");
// All repositories with "repo" in the name should be visible
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
});
it("should show all repositories when search input is cleared", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "repo2");
// Only repo2 should be visible
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
// Clear the search input
await user.clear(searchInput);
// All repositories should be visible again (only those with .openhands)
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
});
it("should handle empty search results", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with non-existent repository name
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "nonexistent");
// No repositories should be visible
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("user/repo2/.openhands"),
).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
});
it("should handle special characters in search", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with special characters
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, ".openhands");
// Only repositories with .openhands should be visible
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
});
it("should maintain accordion functionality with filtered results", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Filter to show only repo2
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, "repo2");
// Click on the filtered repository accordion
const repoAccordion = screen.getByText("user/repo2/.openhands");
await user.click(repoAccordion);
// Wait for microagents to be fetched
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
});
// Check that microagents are displayed
const microagent1 = screen.getByText("test-microagent-1");
const microagent2 = screen.getByText("test-microagent-2");
expect(microagent1).toBeInTheDocument();
expect(microagent2).toBeInTheDocument();
});
it("should handle whitespace in search input", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
// Type in search input with leading/trailing whitespace
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
await user.type(searchInput, " repo2 ");
// repo2 should still be visible (whitespace should be trimmed)
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
});
it("should update search results in real-time", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
});
const searchInput = screen.getByRole("textbox", {
name: /search repositories/i,
});
// Type "repo" - should show repo2
await user.type(searchInput, "repo");
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
// Add "2" to make it "repo2" - should show only repo2
await user.type(searchInput, "2");
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
// Remove "2" to make it "repo" again - should show repo2
await user.keyboard("{Backspace}");
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
});
});
});

View File

@@ -34,7 +34,7 @@
"jose": "^6.0.12",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.257.0",
"posthog-js": "^1.257.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -68,7 +68,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.14",
"@types/node": "^24.0.15",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -82,11 +82,11 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-i18next": "^6.1.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-i18next": "^6.1.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",
@@ -6160,9 +6160,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.0.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz",
"integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==",
"version": "24.0.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.15.tgz",
"integrity": "sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA==",
"devOptional": true,
"dependencies": {
"undici-types": "~7.8.0"
@@ -9017,11 +9017,10 @@
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.5",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz",
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -9083,11 +9082,10 @@
}
},
"node_modules/eslint-plugin-i18next": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.2.tgz",
"integrity": "sha512-hvTmws4kouNHkk314+9MHNj+RQmsqrkejWhTXGlRC0j8H+EXq2qDRLe6UqIjrFZo7/ogyd4btuqsnKCBi8wHbw==",
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.3.tgz",
"integrity": "sha512-z/h4oBRd9wI1ET60HqcLSU6XPeAh/EPOrBBTyCdkWeMoYrWAaUVA+DOQkWTiNIyCltG4NTmy62SQisVXxoXurw==",
"dev": true,
"license": "ISC",
"dependencies": {
"lodash": "^4.17.21",
"requireindex": "~1.1.0"
@@ -9252,11 +9250,10 @@
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.1.tgz",
"integrity": "sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==",
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz",
"integrity": "sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.11.7"
@@ -14268,10 +14265,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.257.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.0.tgz",
"integrity": "sha512-Ujg9RGtWVCu+4tmlRpALSy2ZOZI6JtieSYXIDDdgMWm167KYKvTtbMPHdoBaPWcNu0Km+1hAIBnQFygyn30KhA==",
"license": "SEE LICENSE IN LICENSE",
"version": "1.257.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.1.tgz",
"integrity": "sha512-29kk3IO/LkPQ8E1cds6a2sWr5iN4BovgL+EMzRK9hQXbI6D3FJnQ7zLU6EUpktt6pHnqGpfO3BTEcflcDYkHBg==",
"dependencies": {
"core-js": "^3.38.1",
"fflate": "^0.4.8",

View File

@@ -33,7 +33,7 @@
"jose": "^6.0.12",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.257.0",
"posthog-js": "^1.257.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -92,7 +92,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.14",
"@types/node": "^24.0.15",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -106,11 +106,11 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-i18next": "^6.1.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-i18next": "^6.1.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",

View File

@@ -18,6 +18,7 @@ import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { GitUser, GitRepository, Branch } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { RepositoryMicroagent } from "#/types/microagent-management";
class OpenHands {
private static currentConversation: Conversation | null = null;
@@ -464,6 +465,22 @@ class OpenHands {
return data;
}
/**
* Get the available microagents for a specific repository
* @param owner The repository owner
* @param repo The repository name
* @returns The available microagents for the repository
*/
static async getRepositoryMicroagents(
owner: string,
repo: string,
): Promise<RepositoryMicroagent[]> {
const { data } = await openHands.get<RepositoryMicroagent[]>(
`/api/user/repository/${owner}/${repo}/microagents`,
);
return data;
}
static async getMicroagentPrompt(
conversationId: string,
eventId: number,
@@ -489,24 +506,6 @@ class OpenHands {
return data;
}
/**
* Get the GitHub user installation IDs
* @returns List of GitHub installation IDs
*/
static async getGitHubUserInstallationIds(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/github/installations");
return data;
}
/**
* Get the BitBucket workspaces
* @returns List of BitBucket workspaces
*/
static async getBitBucketWorkspaces(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/bitbucket/installations");
return data;
}
}
export default OpenHands;

View File

@@ -13,6 +13,7 @@ interface ControlsProps {
export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
const { data: conversation } = useActiveConversation();
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
return (
<div className="flex flex-col gap-2 md:items-center md:justify-between md:flex-row">
@@ -37,6 +38,8 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
}}
conversationStatus={conversation?.status}
conversationId={conversation?.conversation_id}
contextMenuOpen={contextMenuOpen}
onContextMenuToggle={setContextMenuOpen}
/>
</div>
);

View File

@@ -35,6 +35,8 @@ interface ConversationCardProps {
conversationStatus?: ConversationStatus;
variant?: "compact" | "default";
conversationId?: string; // Optional conversation ID for VS Code URL
contextMenuOpen?: boolean;
onContextMenuToggle?: (isOpen: boolean) => void;
}
const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
@@ -55,10 +57,11 @@ export function ConversationCard({
conversationStatus = "STOPPED",
variant = "default",
conversationId,
contextMenuOpen = false,
onContextMenuToggle,
}: ConversationCardProps) {
const { t } = useTranslation();
const { parsedEvents } = useWsClient();
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
@@ -101,21 +104,21 @@ export function ConversationCard({
event.preventDefault();
event.stopPropagation();
onDelete?.();
setContextMenuVisible(false);
onContextMenuToggle?.(false);
};
const handleStop = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onStop?.();
setContextMenuVisible(false);
onContextMenuToggle?.(false);
};
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setTitleMode("edit");
setContextMenuVisible(false);
onContextMenuToggle?.(false);
};
const handleDownloadViaVSCode = async (
@@ -141,7 +144,7 @@ export function ConversationCard({
}
}
setContextMenuVisible(false);
onContextMenuToggle?.(false);
};
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -224,15 +227,15 @@ export function ConversationCard({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setContextMenuVisible((prev) => !prev);
onContextMenuToggle?.(!contextMenuOpen);
}}
/>
</div>
)}
<div className="relative">
{contextMenuVisible && (
{contextMenuOpen && (
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onClose={() => onContextMenuToggle?.(false)}
onDelete={onDelete && handleDelete}
onStop={
conversationStatus !== "STOPPED"

View File

@@ -36,6 +36,9 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const [selectedConversationId, setSelectedConversationId] = React.useState<
string | null
>(null);
const [openContextMenuId, setOpenContextMenuId] = React.useState<
string | null
>(null);
const { data: conversations, isFetching, error } = useUserConversations();
@@ -144,6 +147,10 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
createdAt={project.created_at}
conversationStatus={project.status}
conversationId={project.conversation_id}
contextMenuOpen={openContextMenuId === project.conversation_id}
onContextMenuToggle={(isOpen) =>
setOpenContextMenuId(isOpen ? project.conversation_id : null)
}
/>
)}
</NavLink>

View File

@@ -10,9 +10,6 @@ import { BrandButton } from "../settings/brand-button";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useDebounce } from "#/hooks/use-debounce";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { useUserProviders } from "#/hooks/use-user-providers";
import { Provider } from "#/types/settings";
import { SettingsDropdownInput } from "../settings/settings-dropdown-input";
import {
RepositoryDropdown,
RepositoryLoadingState,
@@ -35,10 +32,8 @@ export function RepositorySelectionForm({
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
null,
);
const [selectedProvider, setSelectedProvider] = React.useState<Provider | null>(null);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = React.useRef<boolean>(false);
const { providers } = useUserProviders();
const {
data: repositories,
isLoading: isLoadingRepositories,
@@ -61,13 +56,6 @@ export function RepositorySelectionForm({
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
// Auto-select provider if there's only one
React.useEffect(() => {
if (providers.length === 1 && !selectedProvider) {
setSelectedProvider(providers[0]);
}
}, [providers, selectedProvider]);
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
React.useEffect(() => {
if (
@@ -95,10 +83,8 @@ export function RepositorySelectionForm({
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
// Use all repositories without filtering by provider for now
const allRepositories = repositories?.concat(searchedRepos || []);
const repositoriesItems = (allRepositories || []).map((repo) => ({
const repositoriesItems = allRepositories?.map((repo) => ({
key: repo.id,
label: decodeURIComponent(repo.full_name),
}));
@@ -108,14 +94,6 @@ export function RepositorySelectionForm({
label: branch.name,
}));
// Create provider dropdown items
const providerItems = React.useMemo(() => {
return providers.map(provider => ({
key: provider,
label: provider.charAt(0).toUpperCase() + provider.slice(1), // Capitalize first letter
}));
}, [providers]);
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
if (selectedRepo) onRepoSelection(selectedRepo);
@@ -124,14 +102,6 @@ export function RepositorySelectionForm({
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
};
const handleProviderSelection = (key: React.Key | null) => {
const provider = key as Provider | null;
setSelectedProvider(provider);
setSelectedRepository(null); // Reset repository selection when provider changes
setSelectedBranch(null); // Reset branch selection when provider changes
onRepoSelection(null); // Reset parent component's selected repo
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
@@ -163,26 +133,6 @@ export function RepositorySelectionForm({
}
};
// Render the provider dropdown
const renderProviderSelector = () => {
// Only render if there are multiple providers
if (providers.length <= 1) {
return null;
}
return (
<SettingsDropdownInput
testId="provider-dropdown"
name="provider-dropdown"
placeholder="Select Provider"
items={providerItems}
wrapperClassName="max-w-[500px]"
onSelectionChange={handleProviderSelection}
selectedKey={selectedProvider || undefined}
/>
);
};
// Render the appropriate UI based on the loading/error state
const renderRepositorySelector = () => {
if (isLoadingRepositories) {
@@ -193,15 +143,11 @@ export function RepositorySelectionForm({
return <RepositoryErrorState />;
}
// For now, don't disable the repo dropdown based on provider selection
const isDisabled = false;
return (
<RepositoryDropdown
items={repositoriesItems || []}
onSelectionChange={handleRepoSelection}
onInputChange={handleRepoInputChange}
isDisabled={isDisabled}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
@@ -249,8 +195,8 @@ export function RepositorySelectionForm({
return (
<div className="flex flex-col gap-4">
{renderProviderSelector()}
{renderRepositorySelector()}
{renderBranchSelector()}
<BrandButton

View File

@@ -8,7 +8,6 @@ export interface RepositoryDropdownProps {
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
defaultFilter?: (textValue: string, inputValue: string) => boolean;
isDisabled?: boolean;
}
export function RepositoryDropdown({
@@ -16,7 +15,6 @@ export function RepositoryDropdown({
onSelectionChange,
onInputChange,
defaultFilter,
isDisabled = false,
}: RepositoryDropdownProps) {
const { t } = useTranslation();
@@ -24,13 +22,12 @@ export function RepositoryDropdown({
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder={isDisabled ? t("Please select a provider first") : t(I18nKey.REPOSITORY$SELECT_REPO)}
placeholder={t(I18nKey.REPOSITORY$SELECT_REPO)}
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
defaultFilter={defaultFilter}
isDisabled={isDisabled}
/>
);
}

View File

@@ -0,0 +1,26 @@
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
import { GitRepository } from "#/types/git";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
interface MicroagentManagementAccordionTitleProps {
repository: GitRepository;
}
export function MicroagentManagementAccordionTitle({
repository,
}: MicroagentManagementAccordionTitleProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitProviderIcon gitProvider={repository.git_provider} />
<div
className="text-white text-base font-normal truncate max-w-[150px]"
title={repository.full_name}
>
{repository.full_name}
</div>
</div>
<MicroagentManagementAddMicroagentButton />
</div>
);
}

View File

@@ -13,7 +13,8 @@ export function MicroagentManagementAddMicroagentButton() {
const dispatch = useDispatch();
const handleClick = () => {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible));
};

View File

@@ -1,15 +1,13 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export interface Microagent {
id: string;
name: string;
repositoryUrl: string;
createdAt: string;
}
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
interface MicroagentManagementMicroagentCardProps {
microagent: Microagent;
microagent: {
id: string;
name: string;
createdAt: string;
};
}
export function MicroagentManagementMicroagentCard({
@@ -17,16 +15,20 @@ export function MicroagentManagementMicroagentCard({
}: MicroagentManagementMicroagentCardProps) {
const { t } = useTranslation();
// Format the repository URL to point to the microagent file
const microagentFilePath = `.openhands/microagents/${microagent.name}`;
// Format the createdAt date using MM/DD/YYYY format
const formattedCreatedAt = formatDateMMDDYYYY(new Date(microagent.createdAt));
return (
<div className="rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300">
<div className="text-white text-[16px] font-semibold">
{microagent.name}
</div>
<div className="text-white text-sm font-normal">{microagentFilePath}</div>
<div className="text-white text-sm font-normal">
{microagent.repositoryUrl}
</div>
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {microagent.createdAt}
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
</div>
</div>
);

View File

@@ -1,38 +0,0 @@
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
export function MicroagentManagementMicroagents() {
const microagents = [
{
id: "no-comments",
name: "No comments",
repositoryUrl: "fairwinds/polaris/Repo Overview",
createdAt: "05/30/2025",
},
{
id: "tell-me-a-joke",
name: "Tell me a joke",
repositoryUrl: ".openhands/microagents/Repo Overview",
createdAt: "05/30/2025",
},
];
const numberOfMicroagents = microagents.length;
if (numberOfMicroagents === 0) {
return null;
}
return (
<div>
<div className="flex items-center justify-end pb-4">
<MicroagentManagementAddMicroagentButton />
</div>
{microagents.map((microagent) => (
<div key={microagent.id} className="pb-4">
<MicroagentManagementMicroagentCard microagent={microagent} />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { FaCircleInfo } from "react-icons/fa6";
interface MicroagentManagementNoRepositoriesProps {
title: string;
documentationUrl: string;
}
export function MicroagentManagementNoRepositories({
title,
documentationUrl,
}: MicroagentManagementNoRepositoriesProps) {
return (
<div className="flex items-center justify-center pt-10">
<div className="flex items-center gap-2">
<h2 className="text-white text-sm font-medium">{title}</h2>
<a href={documentationUrl} target="_blank" rel="noopener noreferrer">
<FaCircleInfo className="text-primary" />
</a>
</div>
</div>
);
}

View File

@@ -1,49 +0,0 @@
import {
Microagent,
MicroagentManagementMicroagentCard,
} from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
export interface RepoMicroagent {
id: string;
repositoryName: string;
repositoryUrl: string;
microagents: Microagent[];
}
interface MicroagentManagementRepoMicroagentProps {
repoMicroagent: RepoMicroagent;
}
export function MicroagentManagementRepoMicroagent({
repoMicroagent,
}: MicroagentManagementRepoMicroagentProps) {
const { microagents } = repoMicroagent;
const numberOfMicroagents = microagents.length;
return (
<div className="pb-12">
<div className="flex items-center justify-between pb-4">
<div className="text-white text-base font-normal">
{repoMicroagent.repositoryName}
</div>
<MicroagentManagementAddMicroagentButton />
</div>
{numberOfMicroagents === 0 && (
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
)}
{numberOfMicroagents > 0 && (
<>
{microagents.map((microagent) => (
<div key={microagent.id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard microagent={microagent} />
</div>
))}
</>
)}
</div>
);
}

View File

@@ -1,42 +1,69 @@
import { MicroagentManagementRepoMicroagent } from "./microagent-management-repo-microagent";
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
export function MicroagentManagementRepoMicroagents() {
const repoMicroagents = [
{
id: "rbren/rss-parser",
repositoryName: "rbren/rss-parser",
repositoryUrl: "https://github.com/rbren/rss-parser",
microagents: [],
},
{
id: "fairwinds/polaris",
repositoryName: "fairwinds/polaris",
repositoryUrl: "https://github.com/fairwinds/polaris",
microagents: [
{
id: "no-comments",
name: "No comments",
repositoryUrl: "fairwinds/polaris/Repo Overview",
createdAt: "05/30/2025",
},
],
},
];
export interface RepoMicroagent {
id: string;
repositoryName: string;
repositoryUrl: string;
}
const numberOfRepoMicroagents = repoMicroagents.length;
interface MicroagentManagementRepoMicroagentsProps {
repoMicroagent: RepoMicroagent;
}
if (numberOfRepoMicroagents === 0) {
return null;
export function MicroagentManagementRepoMicroagents({
repoMicroagent,
}: MicroagentManagementRepoMicroagentsProps) {
// Extract owner and repo from repositoryName (format: "owner/repo")
const [owner, repo] = repoMicroagent.repositoryName.split("/");
const {
data: microagents,
isLoading,
isError,
} = useRepositoryMicroagents(owner, repo);
if (isLoading) {
return (
<div className="pb-4 flex justify-center">
<LoadingSpinner size="small" />
</div>
);
}
return (
<div>
{repoMicroagents.map((repoMicroagent) => (
<MicroagentManagementRepoMicroagent
key={repoMicroagent.id}
repoMicroagent={repoMicroagent}
if (isError) {
return (
<div className="pb-4">
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
))}
</div>
);
}
const numberOfMicroagents = microagents?.length || 0;
return (
<div className="pb-4">
{numberOfMicroagents === 0 && (
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
)}
{numberOfMicroagents > 0 &&
microagents?.map((microagent) => (
<div key={microagent.name} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
microagent={{
id: microagent.name,
name: microagent.name,
createdAt: microagent.created_at,
}}
/>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Accordion, AccordionItem } from "@heroui/react";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { GitRepository } from "#/types/git";
import { getGitProviderBaseUrl, cn } from "#/utils/utils";
import { TabType } from "#/types/microagent-management";
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
import { I18nKey } from "#/i18n/declaration";
import { DOCUMENTATION_URL } from "#/utils/constants";
import { MicroagentManagementAccordionTitle } from "./microagent-management-accordion-title";
import { sanitizeQuery } from "#/utils/sanitize-query";
type MicroagentManagementRepositoriesProps = {
repositories: GitRepository[];
tabType: TabType;
};
export function MicroagentManagementRepositories({
repositories,
tabType,
}: MicroagentManagementRepositoriesProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const numberOfRepoMicroagents = repositories.length;
// Filter repositories based on search query
const filteredRepositories = useMemo(() => {
if (!searchQuery.trim()) {
return repositories;
}
const sanitizedQuery = sanitizeQuery(searchQuery);
return repositories.filter((repository) => {
const sanitizedRepoName = sanitizeQuery(repository.full_name);
return sanitizedRepoName.includes(sanitizedQuery);
});
}, [repositories, searchQuery]);
if (numberOfRepoMicroagents === 0) {
if (tabType === "personal") {
return (
<MicroagentManagementNoRepositories
title={t(
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS,
)}
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
/>
);
}
if (tabType === "repositories") {
return (
<MicroagentManagementNoRepositories
title={t(I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS)}
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
/>
);
}
if (tabType === "organizations") {
return (
<MicroagentManagementNoRepositories
title={t(
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS,
)}
documentationUrl={
DOCUMENTATION_URL.MICROAGENTS.ORGANIZATION_AND_USER_MICROAGENTS
}
/>
);
}
}
return (
<div className="flex flex-col gap-4 w-full">
{/* Search Input */}
<div className="flex flex-col gap-2 w-full">
<label htmlFor="repository-search" className="sr-only">
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
</label>
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</div>
{/* Repositories Accordion */}
<Accordion
variant="splitted"
className="w-full px-0 gap-3"
itemClasses={{
base: "shadow-none bg-transparent border border-[#ffffff40] rounded-[6px] cursor-pointer",
trigger: "cursor-pointer",
}}
selectionMode="multiple"
>
{filteredRepositories.map((repository) => (
<AccordionItem
key={repository.id}
aria-label={repository.full_name}
title={
<MicroagentManagementAccordionTitle repository={repository} />
}
>
<MicroagentManagementRepoMicroagents
repoMicroagent={{
id: repository.id,
repositoryName: repository.full_name,
repositoryUrl: `${getGitProviderBaseUrl(repository.git_provider)}/${repository.full_name}`,
}}
/>
</AccordionItem>
))}
</Accordion>
</div>
);
}

View File

@@ -1,12 +1,16 @@
import { Tab, Tabs } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { MicroagentManagementMicroagents } from "./microagent-management-microagents";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { useSelector } from "react-redux";
import { MicroagentManagementRepositories } from "./microagent-management-repositories";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
export function MicroagentManagementSidebarTabs() {
const { t } = useTranslation();
const { repositories, personalRepositories, organizationRepositories } =
useSelector((state: RootState) => state.microagentManagement);
return (
<div className="flex w-full flex-col">
<Tabs
@@ -17,18 +21,27 @@ export function MicroagentManagementSidebarTabs() {
"w-full bg-transparent border border-[#ffffff40] rounded-[6px]",
tab: "px-2 h-[22px]",
tabContent: "text-white text-[12px] font-normal",
panel: "py-0",
panel: "p-0",
cursor: "bg-[#C9B97480] rounded-sm",
}}
>
<Tab key="personal" title={t(I18nKey.COMMON$PERSONAL)}>
<MicroagentManagementMicroagents />
<MicroagentManagementRepositories
repositories={personalRepositories}
tabType="personal"
/>
</Tab>
<Tab key="repositories" title={t(I18nKey.COMMON$REPOSITORIES)}>
<MicroagentManagementRepoMicroagents />
<MicroagentManagementRepositories
repositories={repositories}
tabType="repositories"
/>
</Tab>
<Tab key="organizations" title={t(I18nKey.COMMON$ORGANIZATIONS)}>
<MicroagentManagementMicroagents />
<MicroagentManagementRepositories
repositories={organizationRepositories}
tabType="organizations"
/>
</Tab>
</Tabs>
</div>

View File

@@ -1,11 +1,59 @@
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import {
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
} from "#/state/microagent-management-slice";
import { GitRepository } from "#/types/git";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
export function MicroagentManagementSidebar() {
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: repositories, isLoading } = useUserRepositories();
useEffect(() => {
if (repositories) {
const personalRepos: GitRepository[] = [];
const organizationRepos: GitRepository[] = [];
const otherRepos: GitRepository[] = [];
repositories.forEach((repo: GitRepository) => {
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
personalRepos.push(repo);
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
organizationRepos.push(repo);
} else {
otherRepos.push(repo);
}
});
dispatch(setPersonalRepositories(personalRepos));
dispatch(setOrganizationRepositories(organizationRepos));
dispatch(setRepositories(otherRepos));
}
}, [repositories, dispatch]);
return (
<div className="w-[418px] h-full border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6">
<div className="w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col">
<MicroagentManagementSidebarHeader />
<MicroagentManagementSidebarTabs />
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 flex-1">
<LoadingSpinner size="small" />
<span className="text-sm text-white">
{t("HOME$LOADING_REPOSITORIES")}
</span>
</div>
) : (
<MicroagentManagementSidebarTabs />
)}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import SettingsIcon from "#/icons/settings.svg?react";
import { TooltipButton } from "./tooltip-button";
import { I18nKey } from "#/i18n/declaration";
import { useConfig } from "#/hooks/query/use-config";
interface SettingsButtonProps {
onClick?: () => void;
@@ -13,6 +14,12 @@ export function SettingsButton({
disabled = false,
}: SettingsButtonProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
// Determine the correct settings path based on app mode
// In SaaS mode, navigate directly to user settings to avoid the LLM settings page
const settingsPath =
config?.APP_MODE === "saas" ? "/settings/user" : "/settings";
return (
<TooltipButton
@@ -20,7 +27,7 @@ export function SettingsButton({
tooltip={t(I18nKey.SETTINGS$TITLE)}
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
onClick={onClick}
navLinkTo="/settings"
navLinkTo={settingsPath}
disabled={disabled}
>
<SettingsIcon width={28} height={28} />

View File

@@ -0,0 +1,16 @@
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
import { Provider } from "#/types/settings";
interface GitProviderIconProps {
gitProvider: Provider;
}
export function GitProviderIcon({ gitProvider }: GitProviderIconProps) {
return (
<>
{gitProvider === "github" && <FaGithub size={14} />}
{gitProvider === "gitlab" && <FaGitlab />}
{gitProvider === "bitbucket" && <FaBitbucket />}
</>
);
}

View File

@@ -1,23 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
import OpenHands from "#/api/open-hands";
import { useUserProviders } from "../use-user-providers";
export const useAppInstallations = () => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
const { providers } = useUserProviders();
return useQuery({
queryKey: ["installations", providers, config?.GITHUB_CLIENT_ID],
queryFn: OpenHands.getGitHubUserInstallationIds,
enabled:
userIsAuthenticated &&
providers.includes("github") &&
!!config?.GITHUB_CLIENT_ID &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@@ -1,22 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
import OpenHands from "#/api/open-hands";
import { useUserProviders } from "../use-user-providers";
export const useBitbucketWorkspaces = () => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
const { providers } = useUserProviders();
return useQuery({
queryKey: ["workspaces", providers],
queryFn: OpenHands.getBitBucketWorkspaces,
enabled:
userIsAuthenticated &&
providers.includes("bitbucket") &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@@ -15,7 +15,7 @@ export const useGetGitChanges = () => {
queryKey: ["file_changes", conversationId],
queryFn: () => OpenHands.getGitChanges(conversationId),
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
staleTime: 1000 * 30, // 30 seconds (reduced from 5 minutes to ensure fresher data after git operations)
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: runtimeIsReady && !!conversationId,
meta: {

View File

@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useRepositoryMicroagents = (owner: string, repo: string) =>
useQuery({
queryKey: ["repository", "microagents", owner, repo],
queryFn: () => OpenHands.getRepositoryMicroagents(owner, repo),
enabled: !!owner && !!repo,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -708,4 +708,8 @@ export enum I18nKey {
COMMON$RUN_TEST = "COMMON$RUN_TEST",
COMMON$RUN_APP = "COMMON$RUN_APP",
COMMON$LEARN_FILE_STRUCTURE = "COMMON$LEARN_FILE_STRUCTURE",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS",
COMMON$SEARCH_REPOSITORIES = "COMMON$SEARCH_REPOSITORIES",
}

View File

@@ -11197,7 +11197,7 @@
"fr": "Que souhaitez-vous que le microagent fasse ?",
"tr": "Mikro ajanın ne yapmasını istersiniz?",
"de": "Was soll der Microagent tun?",
"uk": "Що ви хочете, щоб зробив мікроагент?"
"uk": "Що в,и хочете, щоб зробив мікроагент?"
},
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO": {
"en": "Describe what you would like the Microagent to do.",
@@ -11326,5 +11326,69 @@
"tr": "Dosya yapısını öğren",
"de": "Dateistruktur lernen",
"uk": "Вивчити структуру файлів"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS": {
"en": "You do not have user-level microagents",
"ja": "ユーザーレベルのマイクロエージェントがありません",
"zh-CN": "您没有用户级微代理",
"zh-TW": "您沒有使用者層級的微代理",
"ko-KR": "사용자 수준의 마이크로에이전트가 없습니다",
"no": "Du har ikke mikroagenter på brukernivå",
"it": "Non hai microagenti a livello utente",
"pt": "Você não possui microagentes de nível de usuário",
"es": "No tienes microagentes a nivel de usuario",
"ar": "ليس لديك وكلاء دقيقون على مستوى المستخدم",
"fr": "Vous n'avez pas de microagents au niveau utilisateur",
"tr": "Kullanıcı düzeyinde mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten auf Benutzerebene",
"uk": "У вас немає мікроагентів на рівні користувача"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS": {
"en": "You do not have microagents",
"ja": "マイクロエージェントがありません",
"zh-CN": "您没有微代理",
"zh-TW": "您沒有微代理",
"ko-KR": "마이크로에이전트가 없습니다",
"no": "Du har ingen mikroagenter",
"it": "Non hai microagenti",
"pt": "Você não possui microagentes",
"es": "No tienes microagentes",
"ar": "ليس لديك وكلاء دقيقون",
"fr": "Vous n'avez pas de microagents",
"tr": "Mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten",
"uk": "У вас немає мікроагентів"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS": {
"en": "You do not have organization-level microagents",
"ja": "組織レベルのマイクロエージェントがありません",
"zh-CN": "您没有组织级微代理",
"zh-TW": "您沒有組織層級的微代理",
"ko-KR": "조직 수준의 마이크로에이전트가 없습니다",
"no": "Du har ikke mikroagenter på organisasjonsnivå",
"it": "Non hai microagenti a livello organizzazione",
"pt": "Você não possui microagentes de nível organizacional",
"es": "No tienes microagentes a nivel de organización",
"ar": "ليس لديك وكلاء دقيقون على مستوى المؤسسة",
"fr": "Vous n'avez pas de microagents au niveau organisation",
"tr": "Organizasyon düzeyinde mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten auf Organisationsebene",
"uk": "У вас немає мікроагентів на рівні організації"
},
"COMMON$SEARCH_REPOSITORIES": {
"en": "Search repositories",
"ja": "リポジトリを検索",
"zh-CN": "搜索仓库",
"zh-TW": "搜尋存儲庫",
"ko-KR": "저장소 검색",
"no": "Søk i repositories",
"it": "Cerca repository",
"pt": "Pesquisar repositórios",
"es": "Buscar repositorios",
"ar": "البحث في المستودعات",
"fr": "Rechercher des dépôts",
"tr": "Depo ara",
"de": "Repositorys durchsuchen",
"uk": "Пошук репозиторіїв"
}
}

View File

@@ -1,4 +1,5 @@
import { createSlice } from "@reduxjs/toolkit";
import { GitRepository } from "#/types/git";
export const microagentManagementSlice = createSlice({
name: "microagentManagement",
@@ -6,6 +7,9 @@ export const microagentManagementSlice = createSlice({
selectedMicroagent: null,
addMicroagentModalVisible: false,
selectedRepository: null,
personalRepositories: [] as GitRepository[],
organizationRepositories: [] as GitRepository[],
repositories: [] as GitRepository[],
},
reducers: {
setSelectedMicroagent: (state, action) => {
@@ -17,6 +21,15 @@ export const microagentManagementSlice = createSlice({
setSelectedRepository: (state, action) => {
state.selectedRepository = action.payload;
},
setPersonalRepositories: (state, action) => {
state.personalRepositories = action.payload;
},
setOrganizationRepositories: (state, action) => {
state.organizationRepositories = action.payload;
},
setRepositories: (state, action) => {
state.repositories = action.payload;
},
},
});
@@ -24,6 +37,9 @@ export const {
setSelectedMicroagent,
setAddMicroagentModalVisible,
setSelectedRepository,
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
} = microagentManagementSlice.actions;
export default microagentManagementSlice.reducer;

View File

@@ -30,6 +30,7 @@ interface GitRepository {
stargazers_count?: number;
link_header?: string;
pushed_at?: string;
owner_type?: "user" | "organization";
}
interface GitHubCommit {

View File

@@ -0,0 +1,12 @@
export type TabType = "personal" | "repositories" | "organizations";
export interface RepositoryMicroagent {
name: string;
type: "repo" | "knowledge";
content: string;
triggers: string[];
inputs: string[];
tools: string[];
created_at: string;
git_provider: string;
}

View File

@@ -28,3 +28,12 @@ export const JSON_VIEW_THEME = {
base0E: "#c792ea", // keywords, purple
base0F: "#ff5370", // deprecated, red
};
export const DOCUMENTATION_URL = {
MICROAGENTS: {
MICROAGENTS_OVERVIEW:
"https://docs.all-hands.dev/usage/prompting/microagents-overview",
ORGANIZATION_AND_USER_MICROAGENTS:
"https://docs.all-hands.dev/usage/prompting/microagents-org",
},
};

View File

@@ -26,3 +26,19 @@ export const formatTimeDelta = (date: Date) => {
if (months < 12) return `${months}mo`;
return `${years}y`;
};
/**
* Formats a date into a MM/DD/YYYY string format.
* @param date The date to format
* @returns A string in MM/DD/YYYY format
*
* @example
* formatDateMMDDYYYY(new Date("2025-05-30T00:15:08")); // "05/30/2025"
* formatDateMMDDYYYY(new Date("2024-12-25T10:30:00")); // "12/25/2024"
*/
export const formatDateMMDDYYYY = (date: Date) =>
date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});

View File

@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Provider } from "#/types/settings";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -102,3 +103,16 @@ export const formatTimestamp = (timestamp: string) =>
minute: "2-digit",
second: "2-digit",
});
export const getGitProviderBaseUrl = (gitProvider: Provider): string => {
switch (gitProvider) {
case "github":
return "https://github.com";
case "gitlab":
return "https://gitlab.com";
case "bitbucket":
return "https://bitbucket.org";
default:
return "";
}
};

View File

@@ -17,7 +17,9 @@ from openhands.cli.settings import modify_llm_settings_basic
from openhands.cli.shell_config import (
ShellConfigManager,
add_aliases_to_shell_config,
alias_setup_declined,
aliases_exist_in_shell_config,
mark_alias_setup_declined,
)
from openhands.cli.tui import (
UsageMetrics,
@@ -387,106 +389,86 @@ def run_alias_setup_flow(config: OpenHandsConfig) -> None:
Prompts the user to set up aliases for 'openhands' and 'oh' commands.
Handles existing aliases by offering to keep or remove them.
Args:
config: OpenHands configuration
"""
print_formatted_text('')
print_formatted_text(HTML('<gold>🚀 Welcome to OpenHands CLI!</gold>'))
print_formatted_text('')
# Check if aliases already exist
if aliases_exist_in_shell_config():
print_formatted_text(
HTML(
'<grey>We detected existing OpenHands aliases in your shell configuration.</grey>'
)
# Show the normal setup flow
print_formatted_text(
HTML('<grey>Would you like to set up convenient shell aliases?</grey>')
)
print_formatted_text('')
print_formatted_text(
HTML('<grey>This will add the following aliases to your shell profile:</grey>')
)
print_formatted_text(
HTML(
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
print_formatted_text('')
print_formatted_text(
HTML(
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text('')
print_formatted_text(
HTML(
'<ansiyellow>⚠️ Note: This requires uv to be installed first.</ansiyellow>'
)
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases are already configured.</ansigreen>')
)
print_formatted_text(
HTML(
'<ansiyellow> Installation guide: https://docs.astral.sh/uv/getting-started/installation</ansiyellow>'
)
return # Exit early since aliases already exist
else:
# No existing aliases, show the normal setup flow
print_formatted_text(
HTML('<grey>Would you like to set up convenient shell aliases?</grey>')
)
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>This will add the following aliases to your shell profile:</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text('')
print_formatted_text(
HTML(
'<ansiyellow>⚠️ Note: This requires uv to be installed first.</ansiyellow>'
)
)
print_formatted_text(
HTML(
'<ansiyellow> Installation guide: https://docs.astral.sh/uv/getting-started/installation</ansiyellow>'
)
)
print_formatted_text('')
)
print_formatted_text('')
# Use cli_confirm to get user choice
choice = cli_confirm(
config,
'Set up shell aliases?',
['Yes, set up aliases', 'No, skip this step'],
)
# Use cli_confirm to get user choice
choice = cli_confirm(
config,
'Set up shell aliases?',
['Yes, set up aliases', 'No, skip this step'],
)
if choice == 0: # User chose "Yes"
success = add_aliases_to_shell_config()
if success:
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases added successfully!</ansigreen>')
if choice == 0: # User chose "Yes"
success = add_aliases_to_shell_config()
if success:
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases added successfully!</ansigreen>')
)
# Get the appropriate reload command using the shell config manager
shell_manager = ShellConfigManager()
reload_cmd = shell_manager.get_reload_command()
print_formatted_text(
HTML(
f'<grey>Run <b>{reload_cmd}</b> (or restart your terminal) to use the new aliases.</grey>'
)
# Get the appropriate reload command using the shell config manager
shell_manager = ShellConfigManager()
reload_cmd = shell_manager.get_reload_command()
print_formatted_text(
HTML(
f'<grey>Run <b>{reload_cmd}</b> (or restart your terminal) to use the new aliases.</grey>'
)
)
else:
print_formatted_text('')
print_formatted_text(
HTML(
'<ansired>❌ Failed to add aliases. You can set them up manually later.</ansired>'
)
)
else: # User chose "No"
)
else:
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>Skipped alias setup. You can run this setup again anytime.</grey>'
'<ansired>❌ Failed to add aliases. You can set them up manually later.</ansired>'
)
)
else: # User chose "No"
# Mark that the user has declined alias setup
mark_alias_setup_declined()
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>Skipped alias setup. You can run this setup again anytime.</grey>'
)
)
print_formatted_text('')
@@ -583,15 +565,23 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
finalize_config(config)
# Check if we should show the alias setup flow
# Only show it if aliases don't exist in the shell configuration
# and we're in an interactive environment (not during tests or CI)
if not aliases_exist_in_shell_config() and sys.stdin.isatty():
# Clear the terminal if we haven't shown a banner yet
# Only show it if:
# 1. Aliases don't exist in the shell configuration
# 2. User hasn't previously declined alias setup
# 3. We're in an interactive environment (not during tests or CI)
should_show_alias_setup = (
not aliases_exist_in_shell_config()
and not alias_setup_declined()
and sys.stdin.isatty()
)
if should_show_alias_setup:
# Clear the terminal if we haven't shown a banner yet (i.e., setup flow didn't run)
if not banner_shown:
clear()
run_alias_setup_flow(config)
banner_shown = True
# Don't set banner_shown = True here, so the ASCII art banner will still be shown
# TODO: Set working directory from config or use current working directory?
current_dir = config.workspace_base

View File

@@ -277,3 +277,21 @@ def get_shell_config_path() -> Path:
"""Get the path to the shell configuration file."""
manager = ShellConfigManager()
return manager.get_shell_config_path()
def alias_setup_declined() -> bool:
"""Check if the user has previously declined alias setup.
Returns:
True if user has declined alias setup, False otherwise.
"""
marker_file = Path.home() / '.openhands' / '.cli_alias_setup_declined'
return marker_file.exists()
def mark_alias_setup_declined() -> None:
"""Mark that the user has declined alias setup."""
openhands_dir = Path.home() / '.openhands'
openhands_dir.mkdir(exist_ok=True)
marker_file = openhands_dir / '.cli_alias_setup_declined'
marker_file.touch()

View File

@@ -42,6 +42,13 @@ def suppress_cli_warnings():
category=UserWarning,
)
# Suppress LiteLLM close_litellm_async_clients was never awaited warning
warnings.filterwarnings(
'ignore',
message="coroutine 'close_litellm_async_clients' was never awaited",
category=RuntimeWarning,
)
# Apply warning suppressions when module is imported
suppress_cli_warnings()

View File

@@ -9,7 +9,6 @@ from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
InstallationsService,
OwnerType,
ProviderType,
Repository,
@@ -21,7 +20,7 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class BitBucketService(BaseGitService, GitService, InstallationsService):
class BitBucketService(BaseGitService, GitService):
"""Default implementation of GitService for Bitbucket integration.
This is an extension point in OpenHands that allows applications to customize Bitbucket
@@ -186,89 +185,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
return all_items[:max_items] # Trim to max_items if needed
async def get_installations(self) -> list[str]:
workspaces_url = f'{self.BASE_URL}/workspaces'
workspaces = await self._fetch_paginated_data(workspaces_url, {}, 100)
installations: list[str] = []
for workspace in workspaces:
installations.append(workspace['slug'])
return installations
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
"""Get paginated repositories for a specific workspace.
Args:
page: The page number to fetch
per_page: The number of repositories per page
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
Returns:
A list of Repository objects
"""
if not installation_id:
return []
# Convert installation_id to string for use as workspace_slug
workspace_slug = installation_id
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
# Map sort parameter to Bitbucket API compatible values
bitbucket_sort = sort
if sort == 'pushed':
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
elif sort == 'updated':
bitbucket_sort = '-updated_on'
elif sort == 'created':
bitbucket_sort = '-created_on'
elif sort == 'full_name':
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
else:
# Default to most recently updated first
bitbucket_sort = '-updated_on'
params = {
'pagelen': per_page,
'page': page,
'sort': bitbucket_sort,
}
response, headers = await self._make_request(workspace_repos_url, params)
# Extract repositories from the response
repos = response.get('values', [])
# Extract link header for pagination
next_link = response.get('next', '')
repositories = [
Repository(
id=repo.get('uuid', ''),
full_name=f'{repo.get("workspace", {}).get("slug", "")}/{repo.get("slug", "")}',
git_provider=ProviderType.BITBUCKET,
is_public=repo.get('is_private', True) is False,
stargazers_count=None, # Bitbucket doesn't have stars
pushed_at=repo.get('updated_on'),
owner_type=(
OwnerType.ORGANIZATION
if repo.get('workspace', {}).get('is_private') is False
else OwnerType.USER
),
link_header=next_link,
)
for repo in repos
]
return repositories
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
"""Get repositories for the authenticated user using workspaces endpoint.
This method gets all repositories (both public and private) that the user has access to

View File

@@ -15,7 +15,6 @@ from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
InstallationsService,
OwnerType,
ProviderType,
Repository,
@@ -29,7 +28,7 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class GitHubService(BaseGitService, GitService, InstallationsService):
class GitHubService(BaseGitService, GitService):
"""Default implementation of GitService for GitHub integration.
TODO: This doesn't seem a good candidate for the get_impl() pattern. What are the abstract methods we should actually separate and implement here?
@@ -193,47 +192,14 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
ts = repo.get('pushed_at')
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ') if ts else datetime.min
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
):
params = {'page': str(page), 'per_page': str(per_page)}
if installation_id:
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
response, headers = await self._make_request(url, params)
response = response.get('repositories', [])
else:
url = f'{self.BASE_URL}/user/repos'
params['sort'] = sort
response, headers = await self._make_request(url, params)
next_link: str = headers.get('Link', '')
return [
Repository(
id=str(repo.get('id')), # type: ignore[arg-type]
full_name=repo.get('full_name'), # type: ignore[arg-type]
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,
is_public=not repo.get('private', True),
owner_type=(
OwnerType.ORGANIZATION
if repo.get('owner', {}).get('type') == 'Organization'
else OwnerType.USER
),
link_header=next_link,
)
for repo in response
]
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitHub API
all_repos: list[dict] = []
if app_mode == AppMode.SAAS:
# Get all installation IDs and fetch repos for each one
installation_ids = await self.get_installations()
installation_ids = await self.get_installation_ids()
# Iterate through each installation ID
for installation_id in installation_ids:
@@ -280,11 +246,11 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
for repo in all_repos
]
async def get_installations(self) -> list[str]:
async def get_installation_ids(self) -> list[int]:
url = f'{self.BASE_URL}/user/installations'
response, _ = await self._make_request(url)
installations = response.get('installations', [])
return [str(i['id']) for i in installations]
return [i['id'] for i in installations]
async def search_repositories(
self, query: str, per_page: int, sort: str, order: str

View File

@@ -226,49 +226,7 @@ class GitLabService(BaseGitService, GitService):
return repos
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
url = f'{self.BASE_URL}/projects'
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name',
}.get(sort, 'last_activity_at')
params = {
'page': str(page),
'per_page': str(per_page),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'owned': True, # Boolean value without quotes
'membership': True, # Include projects user is a member of
}
response, headers = await self._make_request(url, params)
next_link: str = headers.get('Link', '')
repos = [
Repository(
id=str(repo.get('id')), # type: ignore[arg-type]
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
owner_type=(
OwnerType.ORGANIZATION
if repo.get('namespace', {}).get('kind') == 'group'
else OwnerType.USER
),
link_header=next_link,
)
for repo in response
]
return repos
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from types import MappingProxyType
from typing import Annotated, Any, Coroutine, Literal, cast, overload
from typing import Annotated, Any, Coroutine, Literal, overload
from pydantic import (
BaseModel,
@@ -22,7 +22,6 @@ from openhands.integrations.service_types import (
AuthenticationError,
Branch,
GitService,
InstallationsService,
ProviderType,
Repository,
SuggestedTask,
@@ -161,61 +160,16 @@ class ProviderHandler:
service = self._get_service(provider)
return await service.get_latest_token()
async def get_github_installations(self) -> list[str]:
service = cast(InstallationsService, self._get_service(ProviderType.GITHUB))
try:
return await service.get_installations()
except Exception as e:
logger.warning(f'Failed to get github installations {e}')
return []
async def get_bitbucket_workspaces(self) -> list[str]:
service = cast(InstallationsService, self._get_service(ProviderType.BITBUCKET))
try:
return await service.get_installations()
except Exception as e:
logger.warning(f'Failed to get bitbucket workspaces {e}')
return []
async def get_repositories(
self,
sort: str,
app_mode: AppMode,
selected_provider: ProviderType | None,
page: int | None,
per_page: int | None,
installation_id: str | None,
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
"""
Get repositories from providers
"""
"""
Get repositories from providers
"""
if selected_provider:
if not page or not per_page:
logger.error('Failed to provider params for paginating repos')
return []
service = self._get_service(selected_provider)
try:
return await service.get_paginated_repos(
page, per_page, sort, installation_id
)
except Exception as e:
logger.warning(f'Error fetching repos from {selected_provider}: {e}')
return []
all_repos: list[Repository] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service_repos = await service.get_all_repositories(sort, app_mode)
service_repos = await service.get_repositories(sort, app_mode)
all_repos.extend(service_repos)
except Exception as e:
logger.warning(f'Error fetching repos from {provider}: {e}')

View File

@@ -200,12 +200,6 @@ class BaseGitService(ABC):
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
class InstallationsService(Protocol):
async def get_installations(self) -> list[str]:
"""Get installations for the service; repos live underneath these installations"""
...
class GitService(Protocol):
"""Protocol defining the interface for Git service providers"""
@@ -239,18 +233,10 @@ class GitService(Protocol):
"""Search for repositories"""
...
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
"""Get repositories for the authenticated user"""
...
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
"""Get a page of repositories for the authenticated user"""
...
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories"""
...

View File

@@ -210,6 +210,7 @@ class DockerRuntime(ActionExecutionClient):
extra_deps=self.config.sandbox.runtime_extra_deps,
force_rebuild=self.config.sandbox.force_rebuild_runtime,
extra_build_args=self.config.sandbox.runtime_extra_build_args,
enable_browser=self.config.enable_browser,
)
@staticmethod

View File

@@ -623,8 +623,16 @@ def _create_server(
os.getenv('VSCODE_PORT') or str(find_available_tcp_port(*VSCODE_PORT_RANGE))
)
app_ports = [
int(os.getenv('APP_PORT_1') or str(find_available_tcp_port(*APP_PORT_RANGE_1))),
int(os.getenv('APP_PORT_2') or str(find_available_tcp_port(*APP_PORT_RANGE_2))),
int(
os.getenv('WORK_PORT_1')
or os.getenv('APP_PORT_1')
or str(find_available_tcp_port(*APP_PORT_RANGE_1))
),
int(
os.getenv('WORK_PORT_2')
or os.getenv('APP_PORT_2')
or str(find_available_tcp_port(*APP_PORT_RANGE_2))
),
]
# Get user info

View File

@@ -250,6 +250,7 @@ class RemoteRuntime(ActionExecutionClient):
platform=self.config.sandbox.platform,
extra_deps=self.config.sandbox.runtime_extra_deps,
force_rebuild=self.config.sandbox.force_rebuild_runtime,
enable_browser=self.config.enable_browser,
)
response = self._send_runtime_api_request(

View File

@@ -79,6 +79,9 @@ class GitHandler:
"""
Determines a valid Git reference for comparison.
This method intelligently selects a comparison base that avoids showing
merged changes as if they were user-created changes.
Returns:
str | None: A valid Git reference or None if no valid reference is found.
"""
@@ -90,8 +93,23 @@ class GitHandler:
ref_default_branch = 'origin/' + default_branch
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
# Check if the current branch's remote tracking branch exists
if self._verify_ref_exists(ref_current_branch):
# Check if the current HEAD has diverged significantly from the remote tracking branch
# This happens after merging changes from the default branch
if self._has_diverged_from_remote_tracking_branch(
current_branch, default_branch
):
# If we've diverged due to a merge, prefer using merge-base with default branch
# This prevents showing merged changes as user changes
if self._verify_ref_exists(ref_non_default_branch):
return ref_non_default_branch
# If no significant divergence or merge-base doesn't exist, use the remote tracking branch
return ref_current_branch
# Fallback to other references if remote tracking branch doesn't exist
refs = [
ref_current_branch,
ref_non_default_branch,
ref_default_branch,
ref_new_repo,
@@ -102,6 +120,73 @@ class GitHandler:
return None
def _has_diverged_from_remote_tracking_branch(
self, current_branch: str, default_branch: str
) -> bool:
"""
Checks if the current branch has diverged significantly from its remote tracking branch.
This typically happens after merging changes from the default branch, where the local
branch has new commits but the remote tracking branch hasn't been updated yet.
Args:
current_branch (str): The name of the current branch.
default_branch (str): The name of the default branch.
Returns:
bool: True if the branch has diverged due to a merge, False otherwise.
"""
try:
# Get the commit hash of the current HEAD
head_cmd = 'git --no-pager rev-parse HEAD'
head_result = self.execute(head_cmd, self.cwd)
if head_result.exit_code != 0:
return False
current_head = head_result.content.strip()
# Get the commit hash of the remote tracking branch
remote_branch_cmd = f'git --no-pager rev-parse origin/{current_branch}'
remote_result = self.execute(remote_branch_cmd, self.cwd)
if remote_result.exit_code != 0:
return False
remote_head = remote_result.content.strip()
# If they're the same, no divergence
if current_head == remote_head:
return False
# Check if the current HEAD is ahead of the remote tracking branch
ahead_cmd = f'git --no-pager rev-list --count origin/{current_branch}..HEAD'
ahead_result = self.execute(ahead_cmd, self.cwd)
if ahead_result.exit_code != 0:
return False
commits_ahead = int(ahead_result.content.strip())
# Check if the current HEAD contains commits from the default branch
# that are not in the remote tracking branch
if commits_ahead > 0:
# Check if any of the commits ahead are merge commits or contain changes from default branch
merge_check_cmd = f'git --no-pager log --oneline --merges origin/{current_branch}..HEAD'
merge_result = self.execute(merge_check_cmd, self.cwd)
# If there are merge commits, this indicates a divergence due to merging
if merge_result.exit_code == 0 and merge_result.content.strip():
return True
# Also check if the commits ahead include changes that exist in the default branch
# This catches cases where changes were merged without creating a merge commit
if (
commits_ahead >= 2
): # Threshold to avoid false positives for single commits
return True
return False
except (ValueError, Exception):
# If any error occurs, assume no divergence to be safe
return False
def _get_ref_content(self, file_path: str) -> str:
"""
Retrieves the content of a file from a valid Git reference.
@@ -129,7 +214,14 @@ class GitHandler:
"""
cmd = 'git --no-pager remote show origin | grep "HEAD branch"'
output = self.execute(cmd, self.cwd)
return output.content.split()[-1].strip()
if output.exit_code != 0 or not output.content.strip():
# Fallback to 'main' if no remote origin exists
return 'main'
parts = output.content.split()
if len(parts) == 0:
return 'main'
return parts[-1].strip()
def _get_current_branch(self) -> str:
"""

View File

@@ -32,6 +32,7 @@ def _generate_dockerfile(
base_image: str,
build_from: BuildFromImageType = BuildFromImageType.SCRATCH,
extra_deps: str | None = None,
enable_browser: bool = True,
) -> str:
"""Generate the Dockerfile content for the runtime image based on the base image.
@@ -39,6 +40,7 @@ def _generate_dockerfile(
- base_image (str): The base image provided for the runtime image
- build_from (BuildFromImageType): The build method for the runtime image.
- extra_deps (str):
- enable_browser (bool): Whether to enable browser support (install Playwright)
Returns:
- str: The resulting Dockerfile content
@@ -55,6 +57,7 @@ def _generate_dockerfile(
build_from_scratch=build_from == BuildFromImageType.SCRATCH,
build_from_versioned=build_from == BuildFromImageType.VERSIONED,
extra_deps=extra_deps if extra_deps is not None else '',
enable_browser=enable_browser,
)
return dockerfile_content
@@ -111,6 +114,7 @@ def build_runtime_image(
dry_run: bool = False,
force_rebuild: bool = False,
extra_build_args: list[str] | None = None,
enable_browser: bool = True,
) -> str:
"""Prepares the final docker build folder.
@@ -125,6 +129,7 @@ def build_runtime_image(
- dry_run (bool): if True, it will only ready the build folder. It will not actually build the Docker image
- force_rebuild (bool): if True, it will create the Dockerfile which uses the base_image
- extra_build_args (List[str]): Additional build arguments to pass to the builder
- enable_browser (bool): Whether to enable browser support (install Playwright)
Returns:
- str: <image_repo>:<MD5 hash>. Where MD5 hash is the hash of the docker build folder
@@ -142,6 +147,7 @@ def build_runtime_image(
force_rebuild=force_rebuild,
platform=platform,
extra_build_args=extra_build_args,
enable_browser=enable_browser,
)
return result
@@ -154,6 +160,7 @@ def build_runtime_image(
force_rebuild=force_rebuild,
platform=platform,
extra_build_args=extra_build_args,
enable_browser=enable_browser,
)
return result
@@ -167,9 +174,10 @@ def build_runtime_image_in_folder(
force_rebuild: bool,
platform: str | None = None,
extra_build_args: list[str] | None = None,
enable_browser: bool = True,
) -> str:
runtime_image_repo, _ = get_runtime_image_repo_and_tag(base_image)
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image)}'
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image, enable_browser)}'
versioned_tag = (
# truncate the base image to 96 characters to fit in the tag max length (128 characters)
f'oh_v{oh_version}_{get_tag_for_versioned_image(base_image)}'
@@ -188,6 +196,7 @@ def build_runtime_image_in_folder(
base_image,
build_from=BuildFromImageType.SCRATCH,
extra_deps=extra_deps,
enable_browser=enable_browser,
)
if not dry_run:
_build_sandbox_image(
@@ -226,7 +235,7 @@ def build_runtime_image_in_folder(
else:
logger.debug(f'Build [{hash_image_name}] from scratch')
prep_build_folder(build_folder, base_image, build_from, extra_deps)
prep_build_folder(build_folder, base_image, build_from, extra_deps, enable_browser)
if not dry_run:
_build_sandbox_image(
build_folder,
@@ -251,6 +260,7 @@ def prep_build_folder(
base_image: str,
build_from: BuildFromImageType,
extra_deps: str | None,
enable_browser: bool = True,
) -> None:
# Copy the source code to directory. It will end up in build_folder/code
# If package is not found, build from source code
@@ -282,6 +292,7 @@ def prep_build_folder(
base_image,
build_from=build_from,
extra_deps=extra_deps,
enable_browser=enable_browser,
)
dockerfile_path = Path(build_folder, 'Dockerfile')
with open(str(dockerfile_path), 'w') as f:
@@ -301,10 +312,13 @@ def truncate_hash(hash: str) -> str:
return ''.join(result)
def get_hash_for_lock_files(base_image: str) -> str:
def get_hash_for_lock_files(base_image: str, enable_browser: bool = True) -> str:
openhands_source_dir = Path(openhands.__file__).parent
md5 = hashlib.md5()
md5.update(base_image.encode())
# Only include enable_browser in hash when it's False for backward compatibility
if not enable_browser:
md5.update(str(enable_browser).encode())
for file in ['pyproject.toml', 'poetry.lock']:
src = Path(openhands_source_dir, file)
if not src.exists():
@@ -378,6 +392,10 @@ if __name__ == '__main__':
parser.add_argument('--build_folder', type=str, default=None)
parser.add_argument('--force_rebuild', action='store_true', default=False)
parser.add_argument('--platform', type=str, default=None)
parser.add_argument('--enable_browser', action='store_true', default=True)
parser.add_argument(
'--no_enable_browser', dest='enable_browser', action='store_false'
)
args = parser.parse_args()
if args.build_folder is not None:
@@ -409,6 +427,7 @@ if __name__ == '__main__':
dry_run=True,
force_rebuild=args.force_rebuild,
platform=args.platform,
enable_browser=args.enable_browser,
)
_runtime_image_repo, runtime_image_source_tag = (
@@ -444,6 +463,9 @@ if __name__ == '__main__':
logger.debug('Building image in a temporary folder')
docker_builder = DockerRuntimeBuilder(docker.from_env())
image_name = build_runtime_image(
args.base_image, docker_builder, platform=args.platform
args.base_image,
docker_builder,
platform=args.platform,
enable_browser=args.enable_browser,
)
logger.debug(f'\nBuilt image: {image_name}\n')

View File

@@ -127,7 +127,9 @@ RUN \
/openhands/micromamba/bin/micromamba run -n openhands poetry install --only main,runtime --no-interaction --no-root && \
# Update and install additional tools
# (There used to be an "apt-get update" here, hopefully we can skip it.)
{% if enable_browser %}
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \
{% endif %}
# Set environment variables
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
# Set permissions

View File

@@ -38,55 +38,9 @@ from openhands.server.user_auth import (
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
@app.get('/github/installations', response_model=list[str])
async def get_user_github_installations(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
return await client.get_github_installations()
return JSONResponse(
content='Git provider token required. (such as GitHub).',
status_code=status.HTTP_401_UNAUTHORIZED,
)
@app.get('/bitbucket/installations', response_model=list[str])
async def get_user_bitbucket_installations(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
return await client.get_github_installations()
return JSONResponse(
content='Git provider token required. (such as GitHub).',
status_code=status.HTTP_401_UNAUTHORIZED,
)
@app.get('/repositories', response_model=list[Repository])
async def get_user_repositories(
sort: str = 'pushed',
selected_provider: ProviderType | None = None,
page: int | None = None,
per_page: int | None = None,
installation_id: str | None = None,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
@@ -99,14 +53,7 @@ async def get_user_repositories(
)
try:
return await client.get_repositories(
sort,
server_config.app_mode,
selected_provider,
page,
per_page,
installation_id,
)
return await client.get_repositories(sort, server_config.app_mode)
except AuthenticationError as e:
logger.info(

View File

@@ -212,7 +212,7 @@ def _load_runtime(
runtime_startup_env_vars: dict[str, str] | None = None,
docker_runtime_kwargs: dict[str, str] | None = None,
override_mcp_config: MCPConfig | None = None,
enable_browser: bool = True,
enable_browser: bool = False,
) -> tuple[Runtime, OpenHandsConfig]:
sid = 'rt_' + str(random.randint(100000, 999999))

View File

@@ -38,7 +38,9 @@ def test_view_file(temp_dir, runtime_cls, run_as_openhands):
def test_view_directory(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create test file
test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt')

View File

@@ -36,6 +36,7 @@ def test_browsergym_eval_env(runtime_cls, temp_dir):
base_container_image='xingyaoww/od-eval-miniwob:v1.0',
browsergym_eval_env='browsergym/miniwob.choose-list',
force_rebuild_runtime=True,
enable_browser=True,
)
from openhands.runtime.browser.browser_env import (
BROWSER_EVAL_GET_GOAL_ACTION,

View File

@@ -144,7 +144,9 @@ def test_browser_disabled(temp_dir, runtime_cls, run_as_openhands):
def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
# Test browse
action_cmd = CmdRunAction(command='python3 -m http.server 8000 > server.log 2>&1 &')
@@ -189,7 +191,9 @@ def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
def test_browser_navigation_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser navigation actions: goto, go_back, go_forward, noop."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create test HTML pages
page1_content = """
@@ -322,7 +326,9 @@ def test_browser_navigation_actions(temp_dir, runtime_cls, run_as_openhands):
def test_browser_form_interactions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser form interaction actions: fill, click, select_option, clear."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create a test form page
form_content = """
@@ -536,7 +542,9 @@ fill("{textarea_bid}", "This is a test message")
def test_browser_interactive_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser interactive actions: scroll, hover, fill, press, focus."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create a test page with scrollable content
scroll_content = """
@@ -742,7 +750,9 @@ scroll(0, 400)
def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands):
"""Test browser file upload action."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create a test file to upload
test_file_content = 'This is a test file for upload testing.'
@@ -897,7 +907,9 @@ def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands):
def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create a PDF file using reportlab in the host environment
from reportlab.lib.pagesizes import letter
@@ -969,7 +981,9 @@ def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create a PNG file using PIL in the host environment
from PIL import Image, ImageDraw
@@ -1037,7 +1051,9 @@ def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
def test_download_file(temp_dir, runtime_cls, run_as_openhands):
"""Test downloading a file using the browser."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Minimal PDF content for testing
pdf_content = b"""%PDF-1.4

View File

@@ -128,7 +128,11 @@ async def test_fetch_mcp_via_stdio(temp_dir, runtime_cls, run_as_openhands):
)
override_mcp_config = MCPConfig(stdio_servers=[mcp_stdio_server_config])
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, override_mcp_config=override_mcp_config
temp_dir,
runtime_cls,
run_as_openhands,
override_mcp_config=override_mcp_config,
enable_browser=True,
)
# Test browser server
@@ -220,6 +224,7 @@ async def test_both_stdio_and_sse_mcp(
runtime_cls,
run_as_openhands,
override_mcp_config=override_mcp_config,
enable_browser=True,
)
# ======= Test SSE server =======
@@ -297,6 +302,7 @@ async def test_microagent_and_one_stdio_mcp_in_config(
runtime_cls,
run_as_openhands,
override_mcp_config=override_mcp_config,
enable_browser=True,
)
# NOTE: this simulate the case where the microagent adds a new stdio server to the runtime

View File

@@ -450,7 +450,7 @@ async def test_bitbucket_sort_parameter_mapping():
]
# Call get_repositories with sort='pushed'
await service.get_all_repositories('pushed', AppMode.SAAS)
await service.get_repositories('pushed', AppMode.SAAS)
# Verify that the second call used 'updated_on' instead of 'pushed'
assert mock_request.call_count == 2
@@ -520,7 +520,7 @@ async def test_bitbucket_pagination():
]
# Call get_repositories
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify that all three requests were made (workspaces + 2 pages of repos)
assert mock_request.call_count == 3
@@ -619,7 +619,7 @@ async def test_bitbucket_get_repositories_with_user_owner_type():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_repos]
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -658,7 +658,7 @@ async def test_bitbucket_get_repositories_with_organization_owner_type():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_repos]
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -706,7 +706,7 @@ async def test_bitbucket_get_repositories_mixed_owner_types():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_user_repos, mock_org_repos]
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got repositories from both workspaces
assert len(repositories) == 2
@@ -746,7 +746,7 @@ async def test_bitbucket_get_repositories_owner_type_fallback():
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
mock_fetch.side_effect = [mock_workspaces, mock_repos]
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify all repositories default to USER owner_type for private workspaces
for repo in repositories:

View File

@@ -4,12 +4,16 @@ import tempfile
from pathlib import Path
from unittest.mock import patch
from openhands.cli.main import alias_setup_declined as main_alias_setup_declined
from openhands.cli.main import aliases_exist_in_shell_config, run_alias_setup_flow
from openhands.cli.shell_config import (
ShellConfigManager,
add_aliases_to_shell_config,
aliases_exist_in_shell_config,
alias_setup_declined,
get_shell_config_path,
mark_alias_setup_declined,
)
from openhands.core.config import OpenHandsConfig
def test_get_shell_config_path_no_files_fallback():
@@ -244,3 +248,121 @@ def test_shell_config_manager_template_rendering():
assert 'test-command' in content
assert 'alias openhands="test-command"' in content
assert 'alias oh="test-command"' in content
def test_alias_setup_declined_false():
"""Test alias setup declined check when marker file doesn't exist."""
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
assert alias_setup_declined() is False
def test_alias_setup_declined_true():
"""Test alias setup declined check when marker file exists."""
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
# Create the marker file
mark_alias_setup_declined()
assert alias_setup_declined() is True
def test_mark_alias_setup_declined():
"""Test marking alias setup as declined creates the marker file."""
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
# Initially should be False
assert alias_setup_declined() is False
# Mark as declined
mark_alias_setup_declined()
# Should now be True
assert alias_setup_declined() is True
# Verify the file exists
marker_file = Path(temp_dir) / '.openhands' / '.cli_alias_setup_declined'
assert marker_file.exists()
def test_alias_setup_declined_persisted():
"""Test that when user declines alias setup, their choice is persisted."""
config = OpenHandsConfig()
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
with patch(
'openhands.cli.shell_config.aliases_exist_in_shell_config',
return_value=False,
):
with patch(
'openhands.cli.main.cli_confirm', return_value=1
): # User chooses "No"
with patch('prompt_toolkit.print_formatted_text'):
# Initially, user hasn't declined
assert not alias_setup_declined()
# Run the alias setup flow
run_alias_setup_flow(config)
# After declining, the marker should be set
assert alias_setup_declined()
def test_alias_setup_skipped_when_previously_declined():
"""Test that alias setup is skipped when user has previously declined."""
OpenHandsConfig()
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
# Mark that user has previously declined
mark_alias_setup_declined()
assert alias_setup_declined()
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
with patch(
'openhands.cli.shell_config.aliases_exist_in_shell_config',
return_value=False,
):
with patch('openhands.cli.main.cli_confirm'):
with patch('prompt_toolkit.print_formatted_text'):
# This should not show the setup flow since user previously declined
# We test this by checking the main logic conditions
should_show = (
not aliases_exist_in_shell_config()
and not main_alias_setup_declined()
)
assert not should_show, (
'Alias setup should be skipped when user previously declined'
)
def test_alias_setup_accepted_does_not_set_declined_flag():
"""Test that when user accepts alias setup, no declined marker is created."""
config = OpenHandsConfig()
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
with patch(
'openhands.cli.shell_config.aliases_exist_in_shell_config',
return_value=False,
):
with patch(
'openhands.cli.main.cli_confirm', return_value=0
): # User chooses "Yes"
with patch(
'openhands.cli.shell_config.add_aliases_to_shell_config',
return_value=True,
):
with patch('prompt_toolkit.print_formatted_text'):
# Initially, user hasn't declined
assert not alias_setup_declined()
# Run the alias setup flow
run_alias_setup_flow(config)
# After accepting, the declined marker should still be False
assert not alias_setup_declined()

View File

@@ -0,0 +1,167 @@
import os
import shutil
import subprocess
import tempfile
import unittest
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
class TestGitHandlerMergeFix(unittest.TestCase):
"""Test the fix for git changes showing merged files as user changes."""
def setUp(self):
# Create temporary directories for our test repositories
self.test_dir = tempfile.mkdtemp()
self.origin_dir = os.path.join(self.test_dir, 'origin')
self.local_dir = os.path.join(self.test_dir, 'local')
# Create the directories
os.makedirs(self.origin_dir, exist_ok=True)
os.makedirs(self.local_dir, exist_ok=True)
# Track executed commands for verification
self.executed_commands = []
# Initialize the GitHandler with our real execute function
self.git_handler = GitHandler(self._execute_command)
self.git_handler.set_cwd(self.local_dir)
# Set up the git repositories
self._setup_git_repos()
def tearDown(self):
# Clean up the temporary directories
shutil.rmtree(self.test_dir)
def _execute_command(self, cmd, cwd=None):
"""Execute a shell command and return the result."""
self.executed_commands.append((cmd, cwd))
try:
result = subprocess.run(
cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False
)
return CommandResult(result.stdout, result.returncode)
except Exception as e:
return CommandResult(str(e), 1)
def _setup_git_repos(self):
"""Set up git repositories for testing the merge scenario."""
# Set up origin repository
self._execute_command('git init --initial-branch=main', self.origin_dir)
self._execute_command(
"git config user.email 'test@example.com'", self.origin_dir
)
self._execute_command("git config user.name 'Test User'", self.origin_dir)
# Create initial file and commit
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
f.write('Initial content\n')
self._execute_command('git add file1.txt', self.origin_dir)
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
# Clone to local
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
self._execute_command(
"git config user.email 'test@example.com'", self.local_dir
)
self._execute_command("git config user.name 'Test User'", self.local_dir)
# Create a feature branch
self._execute_command('git checkout -b feature-branch', self.local_dir)
# Make some changes on feature branch
with open(os.path.join(self.local_dir, 'feature_file.txt'), 'w') as f:
f.write('Feature content\n')
self._execute_command('git add feature_file.txt', self.local_dir)
self._execute_command("git commit -m 'Add feature file'", self.local_dir)
# Push feature branch to origin
self._execute_command('git push -u origin feature-branch', self.local_dir)
# Now simulate main branch getting ahead
# Switch to main in origin and add more commits
self._execute_command('git checkout main', self.origin_dir)
with open(os.path.join(self.origin_dir, 'main_file1.txt'), 'w') as f:
f.write('Main content 1\n')
self._execute_command('git add main_file1.txt', self.origin_dir)
self._execute_command("git commit -m 'Add main file 1'", self.origin_dir)
with open(os.path.join(self.origin_dir, 'main_file2.txt'), 'w') as f:
f.write('Main content 2\n')
self._execute_command('git add main_file2.txt', self.origin_dir)
self._execute_command("git commit -m 'Add main file 2'", self.origin_dir)
def test_git_changes_before_merge(self):
"""Test that git changes shows no changes before merge."""
changes = self.git_handler.get_git_changes()
self.assertEqual(changes, [])
def test_git_changes_after_merge_shows_only_user_changes(self):
"""Test that git changes after merge shows only user changes, not merged files."""
# First, fetch latest changes from main
self._execute_command('git fetch origin', self.local_dir)
# Merge main into feature branch
self._execute_command('git merge origin/main', self.local_dir)
# Clear executed commands to start fresh for the git handler calls
self.executed_commands = []
# Get git changes after merge
changes = self.git_handler.get_git_changes()
# Should only show the feature file, not the merged files
self.assertIsNotNone(changes)
self.assertEqual(len(changes), 1)
self.assertEqual(changes[0]['path'], 'feature_file.txt')
self.assertEqual(changes[0]['status'], 'A')
# Verify that the merged files are not shown as changes
paths = [change['path'] for change in changes]
self.assertNotIn('main_file1.txt', paths)
self.assertNotIn('main_file2.txt', paths)
def test_divergence_detection_after_merge(self):
"""Test that divergence detection correctly identifies merge scenarios."""
# Before merge, should not detect divergence
has_diverged_before = (
self.git_handler._has_diverged_from_remote_tracking_branch(
'feature-branch', 'main'
)
)
self.assertFalse(has_diverged_before)
# Fetch and merge
self._execute_command('git fetch origin', self.local_dir)
self._execute_command('git merge origin/main', self.local_dir)
# After merge, should detect divergence
has_diverged_after = self.git_handler._has_diverged_from_remote_tracking_branch(
'feature-branch', 'main'
)
self.assertTrue(has_diverged_after)
def test_valid_ref_selection_after_merge(self):
"""Test that _get_valid_ref selects merge-base after detecting divergence."""
# Fetch and merge
self._execute_command('git fetch origin', self.local_dir)
self._execute_command('git merge origin/main', self.local_dir)
# Clear executed commands to start fresh
self.executed_commands = []
# Get valid ref
valid_ref = self.git_handler._get_valid_ref()
# Should use merge-base instead of remote tracking branch
self.assertIsNotNone(valid_ref)
self.assertIn('merge-base', valid_ref)
self.assertNotEqual(valid_ref, 'origin/feature-branch')
if __name__ == '__main__':
unittest.main()

View File

@@ -112,9 +112,9 @@ async def test_github_get_repositories_with_user_owner_type():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -151,9 +151,9 @@ async def test_github_get_repositories_with_organization_owner_type():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -190,9 +190,9 @@ async def test_github_get_repositories_mixed_owner_types():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -237,9 +237,9 @@ async def test_github_get_repositories_owner_type_fallback():
with (
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
patch.object(service, 'get_installations', return_value=[123]),
patch.object(service, 'get_installation_ids', return_value=[123]),
):
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify all repositories default to USER owner_type
for repo in repositories:

View File

@@ -37,7 +37,7 @@ async def test_gitlab_get_repositories_with_user_owner_type():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -76,7 +76,7 @@ async def test_gitlab_get_repositories_with_organization_owner_type():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -115,7 +115,7 @@ async def test_gitlab_get_repositories_mixed_owner_types():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify we got the expected number of repositories
assert len(repositories) == 2
@@ -162,7 +162,7 @@ async def test_gitlab_get_repositories_owner_type_fallback():
# Mock the pagination response
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
repositories = await service.get_repositories('pushed', AppMode.SAAS)
# Verify all repositories default to USER owner_type
for repo in repositories:

View File

@@ -101,7 +101,7 @@ def test_prep_build_folder(temp_dir):
def test_get_hash_for_lock_files():
with patch('builtins.open', mock_open(read_data='mock-data'.encode())):
hash = get_hash_for_lock_files('some_base_image')
hash = get_hash_for_lock_files('some_base_image', enable_browser=True)
# Since we mocked open to always return "mock_data", the hash is the result
# of hashing the name of the base image followed by "mock-data" twice
md5 = hashlib.md5()
@@ -111,6 +111,31 @@ def test_get_hash_for_lock_files():
assert hash == truncate_hash(md5.hexdigest())
def test_get_hash_for_lock_files_different_enable_browser():
with patch('builtins.open', mock_open(read_data='mock-data'.encode())):
hash_true = get_hash_for_lock_files('some_base_image', enable_browser=True)
hash_false = get_hash_for_lock_files('some_base_image', enable_browser=False)
# Hash with enable_browser=True should not include the enable_browser value
md5_true = hashlib.md5()
md5_true.update('some_base_image'.encode())
for _ in range(2):
md5_true.update('mock-data'.encode())
expected_hash_true = truncate_hash(md5_true.hexdigest())
# Hash with enable_browser=False should include the enable_browser value
md5_false = hashlib.md5()
md5_false.update('some_base_image'.encode())
md5_false.update('False'.encode()) # enable_browser=False is included
for _ in range(2):
md5_false.update('mock-data'.encode())
expected_hash_false = truncate_hash(md5_false.hexdigest())
assert hash_true == expected_hash_true
assert hash_false == expected_hash_false
assert hash_true != hash_false # They should be different
def test_get_hash_for_source_files():
dirhash_mock = MagicMock()
dirhash_mock.return_value = '1f69bd20d68d9e3874d5bf7f7459709b'
@@ -247,7 +272,7 @@ def test_build_runtime_image_from_scratch():
== f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
)
mock_prep_build_folder.assert_called_once_with(
ANY, base_image, BuildFromImageType.SCRATCH, None
ANY, base_image, BuildFromImageType.SCRATCH, None, True
)
@@ -342,6 +367,7 @@ def test_build_runtime_image_exact_hash_not_exist_and_lock_exist():
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag',
BuildFromImageType.LOCK,
None,
True,
)
@@ -401,6 +427,7 @@ def test_build_runtime_image_exact_hash_not_exist_and_lock_not_exist_and_version
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-versioned-tag',
BuildFromImageType.VERSIONED,
None,
True,
)

View File

@@ -60,7 +60,7 @@ class ModalRuntime(ActionExecutionClient):
# Read Modal API credentials from environment variables
modal_token_id = os.getenv('MODAL_TOKEN_ID')
modal_token_secret = os.getenv('MODAL_TOKEN_SECRET')
if not modal_token_id:
raise ValueError('MODAL_TOKEN_ID environment variable is required for Modal runtime')
if not modal_token_secret:
@@ -186,6 +186,7 @@ class ModalRuntime(ActionExecutionClient):
base_image=base_container_image_id,
build_from=BuildFromImageType.SCRATCH,
extra_deps=runtime_extra_deps,
enable_browser=True,
)
base_runtime_image = modal.Image.from_dockerfile(