From df75116184a040ed0564823cc3619268feef19af Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Mon, 21 Jul 2025 22:19:34 +0700 Subject: [PATCH] feat(frontend): Integrate with API to display repositories and their associated microagents. (#9784) Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com> --- .../microagent-management.test.tsx | 468 ++++++++++++++++++ frontend/src/api/open-hands.ts | 17 + .../microagent-management-accordion-title.tsx | 26 + ...agent-management-add-microagent-button.tsx | 3 +- .../microagent-management-microagent-card.tsx | 26 +- .../microagent-management-microagents.tsx | 38 -- .../microagent-management-no-repositories.tsx | 22 + .../microagent-management-repo-microagent.tsx | 49 -- ...microagent-management-repo-microagents.tsx | 93 ++-- .../microagent-management-repositories.tsx | 85 ++++ .../microagent-management-sidebar-tabs.tsx | 25 +- .../microagent-management-sidebar.tsx | 52 +- .../components/shared/git-provider-icon.tsx | 16 + .../hooks/query/use-repository-microagents.ts | 11 + frontend/src/i18n/declaration.ts | 3 + frontend/src/i18n/translation.json | 48 ++ .../src/state/microagent-management-slice.tsx | 16 + frontend/src/types/git.d.ts | 1 + frontend/src/types/microagent-management.tsx | 12 + frontend/src/utils/constants.ts | 9 + frontend/src/utils/format-time-delta.ts | 16 + frontend/src/utils/utils.ts | 14 + 22 files changed, 909 insertions(+), 141 deletions(-) create mode 100644 frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx create mode 100644 frontend/src/components/features/microagent-management/microagent-management-accordion-title.tsx delete mode 100644 frontend/src/components/features/microagent-management/microagent-management-microagents.tsx create mode 100644 frontend/src/components/features/microagent-management/microagent-management-no-repositories.tsx delete mode 100644 frontend/src/components/features/microagent-management/microagent-management-repo-microagent.tsx create mode 100644 frontend/src/components/features/microagent-management/microagent-management-repositories.tsx create mode 100644 frontend/src/components/shared/git-provider-icon.tsx create mode 100644 frontend/src/hooks/query/use-repository-microagents.ts create mode 100644 frontend/src/types/microagent-management.tsx diff --git a/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx b/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx new file mode 100644 index 0000000000..a4c72cb34f --- /dev/null +++ b/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx @@ -0,0 +1,468 @@ +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(, { + 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()), + 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", + }, + ]; + + 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(); + }); +}); diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index 13d4159fea..dd97abf09c 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -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 { + const { data } = await openHands.get( + `/api/user/repository/${owner}/${repo}/microagents`, + ); + return data; + } + static async getMicroagentPrompt( conversationId: string, eventId: number, diff --git a/frontend/src/components/features/microagent-management/microagent-management-accordion-title.tsx b/frontend/src/components/features/microagent-management/microagent-management-accordion-title.tsx new file mode 100644 index 0000000000..c9cf6c696c --- /dev/null +++ b/frontend/src/components/features/microagent-management/microagent-management-accordion-title.tsx @@ -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 ( +
+
+ +
+ {repository.full_name} +
+
+ +
+ ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-add-microagent-button.tsx b/frontend/src/components/features/microagent-management/microagent-management-add-microagent-button.tsx index 7c7225fb5d..4a766530bd 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-add-microagent-button.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-add-microagent-button.tsx @@ -13,7 +13,8 @@ export function MicroagentManagementAddMicroagentButton() { const dispatch = useDispatch(); - const handleClick = () => { + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible)); }; diff --git a/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx b/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx index 130cb13abc..b31c96719d 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx @@ -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 (
{microagent.name}
+
{microagentFilePath}
- {microagent.repositoryUrl} -
-
- {t(I18nKey.COMMON$CREATED_ON)} {microagent.createdAt} + {t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
); diff --git a/frontend/src/components/features/microagent-management/microagent-management-microagents.tsx b/frontend/src/components/features/microagent-management/microagent-management-microagents.tsx deleted file mode 100644 index 4878e9a487..0000000000 --- a/frontend/src/components/features/microagent-management/microagent-management-microagents.tsx +++ /dev/null @@ -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 ( -
-
- -
- {microagents.map((microagent) => ( -
- -
- ))} -
- ); -} diff --git a/frontend/src/components/features/microagent-management/microagent-management-no-repositories.tsx b/frontend/src/components/features/microagent-management/microagent-management-no-repositories.tsx new file mode 100644 index 0000000000..6be34abcb1 --- /dev/null +++ b/frontend/src/components/features/microagent-management/microagent-management-no-repositories.tsx @@ -0,0 +1,22 @@ +import { FaCircleInfo } from "react-icons/fa6"; + +interface MicroagentManagementNoRepositoriesProps { + title: string; + documentationUrl: string; +} + +export function MicroagentManagementNoRepositories({ + title, + documentationUrl, +}: MicroagentManagementNoRepositoriesProps) { + return ( +
+
+

{title}

+ + + +
+
+ ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-repo-microagent.tsx b/frontend/src/components/features/microagent-management/microagent-management-repo-microagent.tsx deleted file mode 100644 index d37cc0bc57..0000000000 --- a/frontend/src/components/features/microagent-management/microagent-management-repo-microagent.tsx +++ /dev/null @@ -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 ( -
-
-
- {repoMicroagent.repositoryName} -
- -
- {numberOfMicroagents === 0 && ( - - )} - {numberOfMicroagents > 0 && ( - <> - {microagents.map((microagent) => ( -
- -
- ))} - - )} -
- ); -} diff --git a/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx b/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx index c52c429547..e44ce9be5b 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-repo-microagents.tsx @@ -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 ( +
+ +
+ ); } - return ( -
- {repoMicroagents.map((repoMicroagent) => ( - + - ))} +
+ ); + } + + const numberOfMicroagents = microagents?.length || 0; + + return ( +
+ {numberOfMicroagents === 0 && ( + + )} + {numberOfMicroagents > 0 && + microagents?.map((microagent) => ( +
+ +
+ ))}
); } diff --git a/frontend/src/components/features/microagent-management/microagent-management-repositories.tsx b/frontend/src/components/features/microagent-management/microagent-management-repositories.tsx new file mode 100644 index 0000000000..1012b35416 --- /dev/null +++ b/frontend/src/components/features/microagent-management/microagent-management-repositories.tsx @@ -0,0 +1,85 @@ +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 } 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"; + +type MicroagentManagementRepositoriesProps = { + repositories: GitRepository[]; + tabType: TabType; +}; + +export function MicroagentManagementRepositories({ + repositories, + tabType, +}: MicroagentManagementRepositoriesProps) { + const { t } = useTranslation(); + + const numberOfRepoMicroagents = repositories.length; + + if (numberOfRepoMicroagents === 0) { + if (tabType === "personal") { + return ( + + ); + } + if (tabType === "repositories") { + return ( + + ); + } + if (tabType === "organizations") { + return ( + + ); + } + } + + return ( + + {repositories.map((repository) => ( + } + > + + + ))} + + ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-sidebar-tabs.tsx b/frontend/src/components/features/microagent-management/microagent-management-sidebar-tabs.tsx index a4d10c259c..5db3a101ca 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-sidebar-tabs.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-sidebar-tabs.tsx @@ -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 (
- + - + - +
diff --git a/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx b/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx index a8f6800f8f..8dd6995276 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-sidebar.tsx @@ -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 ( -
+
- + {isLoading ? ( +
+ + + {t("HOME$LOADING_REPOSITORIES")} + +
+ ) : ( + + )}
); } diff --git a/frontend/src/components/shared/git-provider-icon.tsx b/frontend/src/components/shared/git-provider-icon.tsx new file mode 100644 index 0000000000..ada62208af --- /dev/null +++ b/frontend/src/components/shared/git-provider-icon.tsx @@ -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" && } + {gitProvider === "gitlab" && } + {gitProvider === "bitbucket" && } + + ); +} diff --git a/frontend/src/hooks/query/use-repository-microagents.ts b/frontend/src/hooks/query/use-repository-microagents.ts new file mode 100644 index 0000000000..a7a7ae1ee0 --- /dev/null +++ b/frontend/src/hooks/query/use-repository-microagents.ts @@ -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 + }); diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 3bad412ff7..d125e32f66 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -708,4 +708,7 @@ 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", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index b0b5be3b91..9c8c89da77 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -11326,5 +11326,53 @@ "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": "У вас немає мікроагентів на рівні організації" } } diff --git a/frontend/src/state/microagent-management-slice.tsx b/frontend/src/state/microagent-management-slice.tsx index e6395aff4e..4856c394fb 100644 --- a/frontend/src/state/microagent-management-slice.tsx +++ b/frontend/src/state/microagent-management-slice.tsx @@ -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; diff --git a/frontend/src/types/git.d.ts b/frontend/src/types/git.d.ts index 758399e503..a5f1daa483 100644 --- a/frontend/src/types/git.d.ts +++ b/frontend/src/types/git.d.ts @@ -30,6 +30,7 @@ interface GitRepository { stargazers_count?: number; link_header?: string; pushed_at?: string; + owner_type?: "user" | "organization"; } interface GitHubCommit { diff --git a/frontend/src/types/microagent-management.tsx b/frontend/src/types/microagent-management.tsx new file mode 100644 index 0000000000..970e71e948 --- /dev/null +++ b/frontend/src/types/microagent-management.tsx @@ -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; +} diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 718be6a0c8..36ac68ad43 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -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", + }, +}; diff --git a/frontend/src/utils/format-time-delta.ts b/frontend/src/utils/format-time-delta.ts index 3bd43f14f5..8f2425a234 100644 --- a/frontend/src/utils/format-time-delta.ts +++ b/frontend/src/utils/format-time-delta.ts @@ -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", + }); diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 6fda98bde6..182a563ffa 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -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 ""; + } +};