feat: Handle Click Events for Microagents and Conversations on the Microagent Management Page. (#9853)

This commit is contained in:
Hiep Le 2025-07-23 01:01:49 +07:00 committed by GitHub
parent e045b757fa
commit d567d22748
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1660 additions and 121 deletions

View File

@ -6,6 +6,7 @@ import { createRoutesStub } from "react-router";
import React from "react";
import { renderWithProviders } from "test-utils";
import MicroagentManagement from "#/routes/microagent-management";
import { MicroagentManagementMain } from "#/components/features/microagent-management/microagent-management-main";
import OpenHands from "#/api/open-hands";
import { GitRepository } from "#/types/git";
import { RepositoryMicroagent } from "#/types/microagent-management";
@ -28,12 +29,12 @@ describe("MicroagentManagement", () => {
usage: null,
},
microagentManagement: {
selectedMicroagent: null,
addMicroagentModalVisible: false,
selectedRepository: null,
personalRepositories: [],
organizationRepositories: [],
repositories: [],
selectedMicroagentItem: null,
},
},
});
@ -109,6 +110,7 @@ describe("MicroagentManagement", () => {
tools: [],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/test-microagent-1",
},
{
name: "test-microagent-2",
@ -119,6 +121,7 @@ describe("MicroagentManagement", () => {
tools: [],
created_at: "2021-10-02T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/test-microagent-2",
},
];
@ -168,10 +171,6 @@ describe("MicroagentManagement", () => {
vi.spyOn(OpenHands, "searchConversations").mockResolvedValue([
...mockConversations,
]);
// Mock branches to always return a 'main' branch for the modal
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "abc123", protected: false },
]);
});
it("should render the microagent management page", async () => {
@ -1234,6 +1233,12 @@ describe("MicroagentManagement", () => {
// Add microagent integration tests
describe("Add microagent functionality", () => {
beforeEach(() => {
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "abc123", protected: false },
]);
});
it("should render add microagent button", async () => {
renderMicroagentManagement();
@ -1276,17 +1281,16 @@ describe("MicroagentManagement", () => {
usage: null,
},
microagentManagement: {
selectedMicroagent: null,
selectedMicroagentItem: null,
addMicroagentModalVisible: true, // Start with modal visible
selectedRepository: {
id: "1",
name: "test-repo",
full_name: "user/test-repo",
private: false,
git_provider: "github",
default_branch: "main",
is_public: true,
} as GitRepository,
owner_type: "user",
pushed_at: "2021-10-01T12:00:00Z",
},
personalRepositories: [],
organizationRepositories: [],
repositories: [],
@ -1395,9 +1399,10 @@ describe("MicroagentManagement", () => {
await waitFor(() => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
// Wait for the confirm button to be enabled after entering query and branch selection
// Enter query text
const queryInput = screen.getByTestId("query-input");
await user.type(queryInput, "Test query");
// Wait for the confirm button to be enabled after entering query and branch selection
await waitFor(() => {
const confirmButton = screen.getByTestId("confirm-button");
expect(confirmButton).not.toBeDisabled();
@ -1457,4 +1462,401 @@ describe("MicroagentManagement", () => {
});
});
});
// MicroagentManagementMain component tests
describe("MicroagentManagementMain", () => {
const mockRepositoryMicroagent: RepositoryMicroagent = {
name: "test-microagent",
type: "repo",
content: "Test microagent content",
triggers: ["test", "microagent"],
inputs: [],
tools: [],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/test-microagent",
};
const mockConversationWithPr: Conversation = {
conversation_id: "conv-with-pr",
title: "Test Conversation with PR",
selected_repository: "user/repo2/.openhands",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING",
runtime_status: "STATUS$READY",
trigger: "microagent_management",
url: null,
session_api_key: null,
pr_number: [123],
};
const mockConversationWithoutPr: Conversation = {
conversation_id: "conv-without-pr",
title: "Test Conversation without PR",
selected_repository: "user/repo2/.openhands",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING",
runtime_status: "STATUS$READY",
trigger: "microagent_management",
url: null,
session_api_key: null,
pr_number: [],
};
const mockConversationWithNullPr: Conversation = {
conversation_id: "conv-null-pr",
title: "Test Conversation with null PR",
selected_repository: "user/repo2/.openhands",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING",
runtime_status: null,
trigger: "microagent_management",
url: null,
session_api_key: null,
pr_number: null,
};
const renderMicroagentManagementMain = (selectedMicroagentItem: any) => {
return renderWithProviders(<MicroagentManagementMain />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
addMicroagentModalVisible: false,
selectedRepository: {
id: "1",
full_name: "user/test-repo",
git_provider: "github",
is_public: true,
owner_type: "user",
pushed_at: "2021-10-01T12:00:00Z",
},
personalRepositories: [],
organizationRepositories: [],
repositories: [],
selectedMicroagentItem,
},
},
});
};
it("should render MicroagentManagementDefault when no microagent or conversation is selected", async () => {
renderMicroagentManagementMain(null);
// Check that the default component is rendered
await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT");
expect(
screen.getByText(
"MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
),
).toBeInTheDocument();
});
it("should render MicroagentManagementDefault when selectedMicroagentItem is empty object", async () => {
renderMicroagentManagementMain({});
// Check that the default component is rendered
await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT");
expect(
screen.getByText(
"MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
),
).toBeInTheDocument();
});
it("should render MicroagentManagementViewMicroagent when microagent is selected", async () => {
renderMicroagentManagementMain({
microagent: mockRepositoryMicroagent,
conversation: null,
});
// Check that the microagent view component is rendered
await screen.findByText("test-microagent");
expect(
screen.getByText(".openhands/microagents/test-microagent"),
).toBeInTheDocument();
});
it("should render MicroagentManagementOpeningPr when conversation is selected with empty pr_number array", async () => {
renderMicroagentManagementMain({
microagent: null,
conversation: mockConversationWithoutPr,
});
// Check that the opening PR component is rendered
await screen.findByText(
(content) => content === "COMMON$WORKING_ON_IT!",
{ exact: false },
);
expect(
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
).toBeInTheDocument();
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(1);
});
it("should render MicroagentManagementOpeningPr when conversation is selected with null pr_number", async () => {
const conversationWithNullPr = {
...mockConversationWithoutPr,
pr_number: null,
};
renderMicroagentManagementMain({
microagent: null,
conversation: conversationWithNullPr,
});
// Check that the opening PR component is rendered
await screen.findByText(
(content) => content === "COMMON$WORKING_ON_IT!",
{ exact: false },
);
expect(
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
).toBeInTheDocument();
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(1);
});
it("should render MicroagentManagementReviewPr when conversation is selected with non-empty pr_number array", async () => {
renderMicroagentManagementMain({
microagent: null,
conversation: mockConversationWithPr,
});
// Check that the review PR component is rendered
await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY");
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2);
});
it("should prioritize microagent over conversation when both are present", async () => {
renderMicroagentManagementMain({
microagent: mockRepositoryMicroagent,
conversation: mockConversationWithPr,
});
// Should render the microagent view, not the conversation view
await screen.findByText("test-microagent");
expect(
screen.getByText(".openhands/microagents/test-microagent"),
).toBeInTheDocument();
// Should NOT render the review PR component
expect(
screen.queryByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY"),
).not.toBeInTheDocument();
});
it("should handle conversation with undefined pr_number", async () => {
const conversationWithUndefinedPr = {
...mockConversationWithoutPr,
};
delete conversationWithUndefinedPr.pr_number;
renderMicroagentManagementMain({
microagent: null,
conversation: conversationWithUndefinedPr,
});
// Should render the opening PR component (treats undefined as empty array)
await screen.findByText(
(content) => content === "COMMON$WORKING_ON_IT!",
{ exact: false },
);
expect(
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
).toBeInTheDocument();
});
it("should handle conversation with multiple PR numbers", async () => {
const conversationWithMultiplePrs = {
...mockConversationWithPr,
pr_number: [123, 456, 789],
};
renderMicroagentManagementMain({
microagent: null,
conversation: conversationWithMultiplePrs,
});
// Should render the review PR component (non-empty array)
await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY");
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2);
});
it("should handle conversation with empty string pr_number", async () => {
const conversationWithEmptyStringPr = {
...mockConversationWithoutPr,
pr_number: "",
};
renderMicroagentManagementMain({
microagent: null,
conversation: conversationWithEmptyStringPr,
});
// Should render the opening PR component (treats empty string as empty array)
await screen.findByText(
(content) => content === "COMMON$WORKING_ON_IT!",
{ exact: false },
);
expect(
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
).toBeInTheDocument();
});
it("should handle conversation with zero pr_number", async () => {
const conversationWithZeroPr = {
...mockConversationWithoutPr,
pr_number: 0,
};
renderMicroagentManagementMain({
microagent: null,
conversation: conversationWithZeroPr,
});
// Should render the opening PR component (treats 0 as falsy)
await screen.findByText(
(content) => content === "COMMON$WORKING_ON_IT!",
{ exact: false },
);
expect(
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
).toBeInTheDocument();
});
it("should handle conversation with single PR number as array", async () => {
const conversationWithSinglePr = {
...mockConversationWithPr,
pr_number: [42],
};
renderMicroagentManagementMain({
microagent: null,
conversation: conversationWithSinglePr,
});
// Should render the review PR component (non-empty array)
await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY");
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2);
});
it("should handle edge case with null selectedMicroagentItem", async () => {
renderMicroagentManagementMain(null);
// Should render the default component
await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT");
expect(
screen.getByText(
"MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
),
).toBeInTheDocument();
});
it("should handle edge case with undefined selectedMicroagentItem", async () => {
renderMicroagentManagementMain(undefined);
// Should render the default component
await screen.findByText("MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT");
expect(
screen.getByText(
"MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
),
).toBeInTheDocument();
});
it("should handle conversation with missing pr_number property", async () => {
const conversationWithoutPrNumber = {
conversation_id: "conv-no-pr-number",
title: "Test Conversation without PR number property",
selected_repository: "user/repo2/.openhands",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING",
runtime_status: "STATUS$READY",
trigger: "microagent_management",
url: null,
session_api_key: null,
// pr_number property is missing
};
renderMicroagentManagementMain({
microagent: null,
conversation: conversationWithoutPrNumber,
});
// Should render the opening PR component (undefined pr_number defaults to empty array)
await screen.findByText(
(content) => content === "COMMON$WORKING_ON_IT!",
{ exact: false },
);
expect(
screen.getByText("MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT"),
).toBeInTheDocument();
});
it("should handle microagent with all required properties", async () => {
const completeMicroagent: RepositoryMicroagent = {
name: "complete-microagent",
type: "knowledge",
content: "Complete microagent content with all properties",
triggers: ["complete", "test"],
inputs: ["input1", "input2"],
tools: ["tool1", "tool2"],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/complete-microagent",
};
renderMicroagentManagementMain({
microagent: completeMicroagent,
conversation: null,
});
// Check that the microagent view component is rendered with complete data
await screen.findByText("complete-microagent");
expect(
screen.getByText(".openhands/microagents/complete-microagent"),
).toBeInTheDocument();
});
it("should handle conversation with all required properties", async () => {
const completeConversation: Conversation = {
conversation_id: "complete-conversation",
title: "Complete Conversation",
selected_repository: "user/complete-repo/.openhands",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING",
runtime_status: "STATUS$READY",
trigger: "microagent_management",
url: "https://example.com",
session_api_key: "test-api-key",
pr_number: [999],
};
renderMicroagentManagementMain({
microagent: null,
conversation: completeConversation,
});
// Check that the review PR component is rendered with complete data
await screen.findByText("MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY");
expect(screen.getAllByTestId("view-conversation-button")).toHaveLength(2);
});
});
});

View File

@ -178,9 +178,11 @@ export function MicroagentManagementContent() {
};
return (
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E]">
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E] overflow-hidden">
<MicroagentManagementSidebar />
<MicroagentManagementMain />
<div className="flex-1">
<MicroagentManagementMain />
</div>
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={handleCreateMicroagent}

View File

@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementConversationStopped() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}

View File

@ -0,0 +1,19 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementDefault() {
const { t } = useTranslation();
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
</div>
<div className="text-white text-sm font-normal text-center max-w-[455px]">
{t(
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
)}
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementError() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$ERROR)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}

View File

@ -1,29 +1,52 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { MicroagentManagementDefault } from "./microagent-management-default";
import { MicroagentManagementOpeningPr } from "./microagent-management-opening-pr";
import { MicroagentManagementReviewPr } from "./microagent-management-review-pr";
import { MicroagentManagementViewMicroagent } from "./microagent-management-view-microagent";
import { MicroagentManagementError } from "./microagent-management-error";
import { MicroagentManagementConversationStopped } from "./microagent-management-conversation-stopped";
export function MicroagentManagementMain() {
const { t } = useTranslation();
const { selectedMicroagent } = useSelector(
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
if (!selectedMicroagent) {
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
</div>
<div className="text-white text-sm font-normal text-center max-w-[455px]">
{t(
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
)}
</div>
</div>
);
const { microagent, conversation } = selectedMicroagentItem ?? {};
if (microagent) {
return <MicroagentManagementViewMicroagent />;
}
return null;
if (conversation) {
if (conversation.pr_number && conversation.pr_number.length > 0) {
return <MicroagentManagementReviewPr />;
}
const isConversationStarting =
conversation.status === "STARTING" ||
conversation.runtime_status === "STATUS$STARTING_RUNTIME";
const isConversationOpeningPr =
conversation.status === "RUNNING" &&
conversation.runtime_status === "STATUS$READY";
if (isConversationStarting || isConversationOpeningPr) {
return <MicroagentManagementOpeningPr />;
}
if (conversation.runtime_status === "STATUS$ERROR") {
return <MicroagentManagementError />;
}
if (
conversation.status === "STOPPED" ||
conversation.runtime_status === "STATUS$STOPPED"
) {
return <MicroagentManagementConversationStopped />;
}
return <MicroagentManagementDefault />;
}
return <MicroagentManagementDefault />;
}

View File

@ -1,43 +1,72 @@
import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
import { ConversationStatus } from "#/types/conversation-status";
import { RuntimeStatus } from "#/types/runtime-status";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
import {
setSelectedMicroagentItem,
setSelectedRepository,
} from "#/state/microagent-management-slice";
import { RootState } from "#/store";
import { cn } from "#/utils/utils";
import { GitRepository } from "#/types/git";
interface MicroagentManagementMicroagentCardProps {
microagent: {
id: string;
name: string;
createdAt: string;
conversationStatus?: ConversationStatus;
runtimeStatus?: RuntimeStatus;
prNumber?: number[] | null;
};
showMicroagentFilePath?: boolean;
microagent?: RepositoryMicroagent;
conversation?: Conversation;
repository: GitRepository;
}
export function MicroagentManagementMicroagentCard({
microagent,
showMicroagentFilePath = true,
conversation,
repository,
}: MicroagentManagementMicroagentCardProps) {
const { t } = useTranslation();
const { conversationStatus, runtimeStatus, prNumber } = microagent;
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const {
status: conversationStatus,
runtime_status: runtimeStatus,
pr_number: prNumber,
} = conversation ?? {};
// Format the repository URL to point to the microagent file
const microagentFilePath = `.openhands/microagents/${microagent.name}`;
const microagentFilePath = microagent
? `.openhands/microagents/${microagent.name}`
: "";
// Format the createdAt date using MM/DD/YYYY format
const formattedCreatedAt = formatDateMMDDYYYY(new Date(microagent.createdAt));
const formattedCreatedAt = useMemo(() => {
if (microagent) {
return formatDateMMDDYYYY(new Date(microagent.created_at));
}
if (conversation) {
return formatDateMMDDYYYY(new Date(conversation.created_at));
}
return "";
}, [microagent, conversation]);
const hasPr = prNumber && prNumber.length > 0;
const hasPr = !!(prNumber && prNumber.length > 0);
// Helper function to get status text
const statusText = useMemo(() => {
if (hasPr) {
return t(I18nKey.COMMON$READY_FOR_REVIEW);
}
if (
conversationStatus === "STARTING" ||
runtimeStatus === "STATUS$STARTING_RUNTIME"
) {
return t(I18nKey.COMMON$STARTING);
}
if (
conversationStatus === "STOPPED" ||
runtimeStatus === "STATUS$STOPPED"
@ -47,27 +76,60 @@ export function MicroagentManagementMicroagentCard({
if (runtimeStatus === "STATUS$ERROR") {
return t(I18nKey.MICROAGENT$STATUS_ERROR);
}
if (
(conversationStatus === "STARTING" || conversationStatus === "RUNNING") &&
runtimeStatus === "STATUS$READY"
) {
if (conversationStatus === "RUNNING" && runtimeStatus === "STATUS$READY") {
return t(I18nKey.MICROAGENT$STATUS_OPENING_PR);
}
return "";
}, [conversationStatus, runtimeStatus, t, hasPr]);
const cardTitle = microagent?.name ?? conversation?.title;
const isCardSelected = useMemo(() => {
if (microagent && selectedMicroagentItem?.microagent) {
return selectedMicroagentItem.microagent.name === microagent.name;
}
if (conversation && selectedMicroagentItem?.conversation) {
return (
selectedMicroagentItem.conversation.conversation_id ===
conversation.conversation_id
);
}
return false;
}, [microagent, conversation, selectedMicroagentItem]);
const onMicroagentCardClicked = () => {
dispatch(
setSelectedMicroagentItem(
microagent
? {
microagent,
conversation: null,
}
: {
microagent: null,
conversation,
},
),
);
dispatch(setSelectedRepository(repository));
};
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={cn(
"rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300",
isCardSelected && "bg-[#ffffff33] border-[#C9B974]",
)}
onClick={onMicroagentCardClicked}
>
<div className="flex flex-col items-start gap-2">
{statusText && (
<div className="px-[6px] py-[2px] text-[11px] font-medium bg-[#C9B97433] text-white rounded-2xl">
{statusText}
</div>
)}
<div className="text-white text-[16px] font-semibold">
{microagent.name}
</div>
{showMicroagentFilePath && (
<div className="text-white text-[16px] font-semibold">{cardTitle}</div>
{!!microagent && (
<div className="text-white text-sm font-normal">
{microagentFilePath}
</div>

View File

@ -0,0 +1,47 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementOpeningPr() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-semibold pb-2">
{t(I18nKey.COMMON$WORKING_ON_IT)}!
</div>
<div className="text-[#ffffff99] text-[18px] font-normal text-center max-w-[518px] pb-[22px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}

View File

@ -1,24 +1,34 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
export interface RepoMicroagent {
id: string;
repositoryName: string;
repositoryUrl: string;
}
import { GitRepository } from "#/types/git";
import { getGitProviderBaseUrl } from "#/utils/utils";
import { RootState } from "#/store";
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
interface MicroagentManagementRepoMicroagentsProps {
repoMicroagent: RepoMicroagent;
repository: GitRepository;
}
export function MicroagentManagementRepoMicroagents({
repoMicroagent,
repository,
}: MicroagentManagementRepoMicroagentsProps) {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const { full_name: repositoryName, git_provider: gitProvider } = repository;
// Extract owner and repo from repositoryName (format: "owner/repo")
const [owner, repo] = repoMicroagent.repositoryName.split("/");
const [owner, repo] = repositoryName.split("/");
const repositoryUrl = `${getGitProviderBaseUrl(gitProvider)}/${repositoryName}`;
const {
data: microagents,
@ -30,11 +40,28 @@ export function MicroagentManagementRepoMicroagents({
data: conversations,
isLoading: isLoadingConversations,
isError: isErrorConversations,
} = useSearchConversations(
repoMicroagent.repositoryName,
"microagent_management",
1000,
);
} = useSearchConversations(repositoryName, "microagent_management", 1000);
useEffect(() => {
const hasConversations = conversations && conversations.length > 0;
const selectedConversation = selectedMicroagentItem?.conversation;
if (hasConversations && selectedConversation) {
// get the latest selected conversation.
const latestSelectedConversation = conversations.find(
(conversation) =>
conversation.conversation_id === selectedConversation.conversation_id,
);
if (latestSelectedConversation) {
dispatch(
setSelectedMicroagentItem({
microagent: null,
conversation: latestSelectedConversation,
}),
);
}
}
}, [conversations]);
// Show loading only when both queries are loading
const isLoading = isLoadingMicroagents || isLoadingConversations;
@ -54,9 +81,7 @@ export function MicroagentManagementRepoMicroagents({
if (isError) {
return (
<div className="pb-4">
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
</div>
);
}
@ -68,9 +93,7 @@ export function MicroagentManagementRepoMicroagents({
return (
<div className="pb-4">
{totalItems === 0 && (
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
)}
{/* Render microagents */}
@ -78,11 +101,8 @@ export function MicroagentManagementRepoMicroagents({
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,
}}
microagent={microagent}
repository={repository}
/>
</div>
))}
@ -92,15 +112,8 @@ export function MicroagentManagementRepoMicroagents({
conversations?.map((conversation) => (
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
microagent={{
id: conversation.conversation_id,
name: conversation.title,
createdAt: conversation.created_at,
conversationStatus: conversation.status,
runtimeStatus: conversation.runtime_status || undefined,
prNumber: conversation.pr_number || undefined,
}}
showMicroagentFilePath={false}
conversation={conversation}
repository={repository}
/>
</div>
))}

View File

@ -3,7 +3,7 @@ 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 { cn } from "#/utils/utils";
import { TabType } from "#/types/microagent-management";
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
import { I18nKey } from "#/i18n/declaration";
@ -110,13 +110,7 @@ export function MicroagentManagementRepositories({
<MicroagentManagementAccordionTitle repository={repository} />
}
>
<MicroagentManagementRepoMicroagents
repoMicroagent={{
id: repository.id,
repositoryName: repository.full_name,
repositoryUrl: `${getGitProviderBaseUrl(repository.git_provider)}/${repository.full_name}`,
}}
/>
<MicroagentManagementRepoMicroagents repository={repository} />
</AccordionItem>
))}
</Accordion>

View File

@ -0,0 +1,74 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { getProviderName, constructPullRequestUrl } from "#/utils/utils";
import { Provider } from "#/types/settings";
import { RootState } from "#/store";
export function MicroagentManagementReviewPr() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const {
conversation_id: conversationId,
selected_repository: selectedRepository,
git_provider: gitProvider,
pr_number: prNumber,
} = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY)}
</div>
<div className="flex gap-[22px]">
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
<a
href={
selectedRepository && gitProvider && prNumber && prNumber.length > 0
? constructPullRequestUrl(
prNumber[0],
gitProvider,
selectedRepository,
)
: "/#"
}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="primary"
testId="view-conversation-button"
>
{`${t(I18nKey.COMMON$REVIEW_PR_IN)} ${getProviderName(
gitProvider as Provider,
)}`}
</BrandButton>
</a>
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import QuestionCircleIcon from "#/icons/question-circle.svg?react";
import { DOCUMENTATION_URL } from "#/utils/constants";
export function MicroagentManagementSidebarHeader() {
const { t } = useTranslation();
@ -12,7 +13,13 @@ export function MicroagentManagementSidebarHeader() {
</h1>
<p className="text-white text-sm font-normal leading-[20px] pt-2">
{t(I18nKey.MICROAGENT_MANAGEMENT$USE_MICROAGENTS)}
<QuestionCircleIcon className="inline-block ml-1" />
<a
href={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
target="_blank"
rel="noopener noreferrer"
>
<QuestionCircleIcon className="inline-block ml-1" />
</a>
</p>
</div>
);

View File

@ -0,0 +1,73 @@
import { useSelector } from "react-redux";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { code } from "../markdown/code";
import { ul, ol } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
import { anchor } from "../markdown/anchor";
import { RootState } from "#/store";
export function MicroagentManagementViewMicroagentContent() {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
const transformMicroagentContent = (): string => {
if (!microagent) {
return "";
}
// If no triggers exist, return the content as-is
if (!microagent.triggers || microagent.triggers.length === 0) {
return microagent.content;
}
// Create the triggers frontmatter
const triggersFrontmatter = `
---
triggers:
${microagent.triggers.map((trigger) => ` - ${trigger}`).join("\n")}
---
`;
// Prepend the frontmatter to the content
return `
${triggersFrontmatter}
${microagent.content}
`;
};
if (!microagent || !selectedRepository) {
return null;
}
// Transform the content to include triggers frontmatter if applicable
const transformedContent = transformMicroagentContent();
return (
<div className="w-full h-full p-6 bg-[#ffffff1a] rounded-2xl text-white text-sm">
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{transformedContent}
</Markdown>
</div>
);
}

View File

@ -0,0 +1,60 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RootState } from "#/store";
import { BrandButton } from "../settings/brand-button";
import { getProviderName, constructMicroagentUrl } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementViewMicroagentHeader() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
if (!microagent || !selectedRepository) {
return null;
}
// Construct the microagent URL
const microagentUrl = constructMicroagentUrl(
selectedRepository.git_provider,
selectedRepository.full_name,
microagent.path,
);
return (
<div className="flex items-center justify-between pb-2">
<span className="text-sm text-[#ffffff99]">
{selectedRepository.full_name}
</span>
<div className="flex items-center justify-end gap-2">
<a href={microagentUrl} target="_blank" rel="noopener noreferrer">
<BrandButton
type="button"
variant="secondary"
testId="edit-in-git-button"
className="py-1 px-2"
>
{`${t(I18nKey.COMMON$EDIT_IN)} ${getProviderName(selectedRepository.git_provider)}`}
</BrandButton>
</a>
<BrandButton
type="button"
variant="primary"
onClick={() => {}}
testId="learn-button"
className="py-1 px-2"
>
{t(I18nKey.COMMON$LEARN)}
</BrandButton>
</div>
</div>
);
}

View File

@ -0,0 +1,35 @@
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { MicroagentManagementViewMicroagentHeader } from "./microagent-management-view-microagent-header";
import { MicroagentManagementViewMicroagentContent } from "./microagent-management-view-microagent-content";
export function MicroagentManagementViewMicroagent() {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
if (!microagent || !selectedRepository) {
return null;
}
return (
<div className="flex flex-col w-full h-full p-6 overflow-auto">
<MicroagentManagementViewMicroagentHeader />
<span className="text-white text-2xl font-medium pb-2">
{microagent.name}
</span>
<span className="text-white text-lg font-medium pb-6">
{microagent.path}
</span>
<div className="flex-1">
<MicroagentManagementViewMicroagentContent />
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import { cn } from "#/utils/utils";
interface LoaderProps {
size?: "small" | "medium" | "large";
className?: string;
}
export function Loader({ size = "medium", className }: LoaderProps) {
const sizeClasses = {
small: "w-3 h-3",
medium: "w-4 h-4",
large: "w-5 h-5",
};
const dotSize = sizeClasses[size];
return (
<div
data-testid="loader"
className={cn("flex items-center justify-center", className)}
>
<div className={cn("loader rounded-full", dotSize)} />
</div>
);
}

View File

@ -717,4 +717,13 @@ export enum I18nKey {
COMMON$COMPLETED = "COMMON$COMPLETED",
COMMON$COMPLETED_PARTIALLY = "COMMON$COMPLETED_PARTIALLY",
COMMON$STOPPED = "COMMON$STOPPED",
COMMON$WORKING_ON_IT = "COMMON$WORKING_ON_IT",
MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT = "MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT",
MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY = "MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY",
COMMON$REVIEW_PR_IN = "COMMON$REVIEW_PR_IN",
COMMON$EDIT_IN = "COMMON$EDIT_IN",
COMMON$LEARN = "COMMON$LEARN",
COMMON$STARTING = "COMMON$STARTING",
MICROAGENT_MANAGEMENT$ERROR = "MICROAGENT_MANAGEMENT$ERROR",
MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED = "MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED",
}

View File

@ -11470,5 +11470,149 @@
"tr": "Durduruldu",
"de": "Gestoppt",
"uk": "Зупинено"
},
"COMMON$WORKING_ON_IT": {
"en": "Working on it",
"ja": "作業中",
"zh-CN": "正在处理",
"zh-TW": "正在處理",
"ko-KR": "작업 중",
"no": "Jobber med det",
"it": "Ci sto lavorando",
"pt": "Trabalhando nisso",
"es": "Trabajando en ello",
"ar": "يتم العمل عليه",
"fr": "En cours",
"tr": "Üzerinde çalışılıyor",
"de": "Wird bearbeitet",
"uk": "В процесі виконання"
},
"MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT": {
"en": "We're working on it! Once OpenHands is done investigating, you'll be able to review its pull request before merging your new microagent.",
"ja": "作業中ですOpenHandsの調査が完了すると、新しいマイクロエージェントをマージする前にプルリクエストを確認できます。",
"zh-CN": "我们正在处理OpenHands 调查完成后,您将能够在合并新微代理之前审查其拉取请求。",
"zh-TW": "我們正在處理OpenHands 調查完成後,您將能在合併新微代理前審查其拉取請求。",
"ko-KR": "작업 중입니다! OpenHands의 조사가 끝나면 새 마이크로에이전트를 병합하기 전에 풀 리퀘스트를 검토할 수 있습니다.",
"no": "Vi jobber med det! Når OpenHands er ferdig med å undersøke, kan du gjennomgå pull requesten før du slår sammen din nye mikroagent.",
"it": "Ci stiamo lavorando! Una volta che OpenHands avrà terminato l'analisi, potrai rivedere la pull request prima di unire il tuo nuovo microagent.",
"pt": "Estamos trabalhando nisso! Assim que o OpenHands terminar a investigação, você poderá revisar o pull request antes de mesclar seu novo microagente.",
"es": "¡Estamos trabajando en ello! Una vez que OpenHands termine de investigar, podrás revisar su pull request antes de fusionar tu nuevo microagente.",
"ar": "نحن نعمل على ذلك! بمجرد أن ينتهي OpenHands من التحقيق، ستتمكن من مراجعة طلب السحب قبل دمج وكيلك الدقيق الجديد.",
"fr": "Nous y travaillons ! Une fois qu'OpenHands aura terminé l'investigation, vous pourrez examiner sa pull request avant de fusionner votre nouveau microagent.",
"tr": "Üzerinde çalışıyoruz! OpenHands incelemeyi bitirdiğinde, yeni mikro ajanınızı birleştirmeden önce pull request'i gözden geçirebileceksiniz.",
"de": "Wir arbeiten daran! Sobald OpenHands die Untersuchung abgeschlossen hat, können Sie den Pull Request überprüfen, bevor Sie Ihren neuen Microagenten zusammenführen.",
"uk": "Ми працюємо над цим! Після завершення розслідування OpenHands ви зможете переглянути його pull request перед об'єднанням нового мікроагента."
},
"MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY": {
"en": "Your microagent is ready! Merge the PR in GitHub to start using it.",
"ja": "マイクロエージェントの準備ができましたGitHubでPRをマージして使い始めましょう。",
"zh-CN": "您的微代理已准备就绪!在 GitHub 上合并 PR 即可开始使用。",
"zh-TW": "您的微代理已準備就緒!在 GitHub 上合併 PR 即可開始使用。",
"ko-KR": "마이크로에이전트가 준비되었습니다! GitHub에서 PR을 병합하여 사용을 시작하세요.",
"no": "Din mikroagent er klar! Slå sammen PR-en i GitHub for å begynne å bruke den.",
"it": "Il tuo microagente è pronto! Unisci la PR su GitHub per iniziare a usarlo.",
"pt": "Seu microagente está pronto! Faça o merge do PR no GitHub para começar a usá-lo.",
"es": "¡Tu microagente está listo! Haz merge del PR en GitHub para empezar a usarlo.",
"ar": "وكيلك المصغر جاهز! ادمج طلب السحب في GitHub لبدء استخدامه.",
"fr": "Votre micro-agent est prêt ! Fusionnez la PR sur GitHub pour commencer à l'utiliser.",
"tr": "Mikro ajanınız hazır! Kullanmak için GitHub'da PR'ı birleştirin.",
"de": "Ihr Microagent ist bereit! Führen Sie den PR in GitHub zusammen, um ihn zu verwenden.",
"uk": "Ваш мікроагент готовий! Злийте PR у GitHub, щоб почати ним користуватися."
},
"COMMON$REVIEW_PR_IN": {
"en": "Review PR in",
"ja": "でPRをレビュー",
"zh-CN": "在中审查PR",
"zh-TW": "在中審查PR",
"ko-KR": "에서 PR 검토",
"no": "Se gjennom PR i",
"it": "Revisiona la PR su",
"pt": "Revisar PR em",
"es": "Revisar PR en",
"ar": "مراجعة PR في",
"fr": "Examiner la PR sur",
"tr": "PR'ı şurada gözden geçir:",
"de": "PR überprüfen in",
"uk": "Переглянути PR у"
},
"COMMON$EDIT_IN": {
"en": "Edit in",
"ja": "で編集",
"zh-CN": "在中编辑",
"zh-TW": "在中編輯",
"ko-KR": "에서 편집",
"no": "Rediger i",
"it": "Modifica su",
"pt": "Editar em",
"es": "Editar en",
"ar": "تعديل في",
"fr": "Modifier dans",
"tr": "Şurada düzenle:",
"de": "Bearbeiten in",
"uk": "Редагувати у"
},
"COMMON$LEARN": {
"en": "Learn",
"ja": "学ぶ",
"zh-CN": "学习",
"zh-TW": "學習",
"ko-KR": "학습",
"no": "Lær",
"it": "Impara",
"pt": "Aprender",
"es": "Aprender",
"ar": "تعلم",
"fr": "Apprendre",
"tr": "Öğren",
"de": "Lernen",
"uk": "Вчитися"
},
"COMMON$STARTING": {
"en": "Starting",
"ja": "開始中",
"zh-CN": "启动中",
"zh-TW": "啟動中",
"ko-KR": "시작 중",
"no": "Starter",
"it": "Avvio",
"pt": "Iniciando",
"es": "Iniciando",
"ar": "جارٍ البدء",
"fr": "Démarrage",
"tr": "Başlatılıyor",
"de": "Wird gestartet",
"uk": "Запуск"
},
"MICROAGENT_MANAGEMENT$ERROR": {
"en": "The system has encountered an error. Please try again later.",
"ja": "システムでエラーが発生しました。後でもう一度お試しください。",
"zh-CN": "系统遇到错误。请稍后再试。",
"zh-TW": "系統發生錯誤。請稍後再試。",
"ko-KR": "시스템에 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
"no": "Systemet har oppdaget en feil. Prøv igjen senere.",
"it": "Il sistema ha riscontrato un errore. Riprova più tardi.",
"pt": "O sistema encontrou um erro. Por favor, tente novamente mais tarde.",
"es": "El sistema ha encontrado un error. Por favor, inténtalo de nuevo más tarde.",
"ar": "واجه النظام خطأ. يرجى المحاولة مرة أخرى لاحقًا.",
"fr": "Le système a rencontré une erreur. Veuillez réessayer plus tard.",
"tr": "Sistem bir hata ile karşılaştı. Lütfen daha sonra tekrar deneyin.",
"de": "Das System hat einen Fehler festgestellt. Bitte versuchen Sie es später erneut.",
"uk": "Система зіткнулася з помилкою. Будь ласка, спробуйте пізніше."
},
"MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED": {
"en": "The conversation has been stopped.",
"ja": "会話が停止されました。",
"zh-CN": "对话已被停止。",
"zh-TW": "對話已被停止。",
"ko-KR": "대화가 중단되었습니다.",
"no": "Samtalen har blitt stoppet.",
"it": "La conversazione è stata interrotta.",
"pt": "A conversa foi interrompida.",
"es": "La conversación ha sido detenida.",
"ar": "تم إيقاف المحادثة.",
"fr": "La conversation a été arrêtée.",
"tr": "Konuşma durduruldu.",
"de": "Das Gespräch wurde gestoppt.",
"uk": "Розмову зупинено."
}
}

View File

@ -1,20 +1,18 @@
import { createSlice } from "@reduxjs/toolkit";
import { GitRepository } from "#/types/git";
import { IMicroagentItem } from "#/types/microagent-management";
export const microagentManagementSlice = createSlice({
name: "microagentManagement",
initialState: {
selectedMicroagent: null,
addMicroagentModalVisible: false,
selectedRepository: null as GitRepository | null,
personalRepositories: [] as GitRepository[],
organizationRepositories: [] as GitRepository[],
repositories: [] as GitRepository[],
selectedMicroagentItem: null as IMicroagentItem | null,
},
reducers: {
setSelectedMicroagent: (state, action) => {
state.selectedMicroagent = action.payload;
},
setAddMicroagentModalVisible: (state, action) => {
state.addMicroagentModalVisible = action.payload;
},
@ -30,16 +28,19 @@ export const microagentManagementSlice = createSlice({
setRepositories: (state, action) => {
state.repositories = action.payload;
},
setSelectedMicroagentItem: (state, action) => {
state.selectedMicroagentItem = action.payload;
},
},
});
export const {
setSelectedMicroagent,
setAddMicroagentModalVisible,
setSelectedRepository,
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
setSelectedMicroagentItem,
} = microagentManagementSlice.actions;
export default microagentManagementSlice.reducer;

View File

@ -20,3 +20,27 @@
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}
.loader {
background: #C9B974;
animation: l5 1s infinite linear alternate;
}
@keyframes l5 {
0% {
box-shadow: 20px 0 #C9B974, -20px 0 rgba(201,185,116,0.1);
background: #C9B974;
}
33% {
box-shadow: 20px 0 #C9B974, -20px 0 rgba(201,185,116,0.1);
background: rgba(201,185,116,0.1);
}
66% {
box-shadow: 20px 0 rgba(201,185,116,0.1), -20px 0 #C9B974;
background: rgba(201,185,116,0.1);
}
100% {
box-shadow: 20px 0 rgba(201,185,116,0.1), -20px 0 #C9B974;
background: #C9B974;
}
}

View File

@ -1,3 +1,5 @@
import { Conversation } from "#/api/open-hands.types";
export type TabType = "personal" | "repositories" | "organizations";
export interface RepositoryMicroagent {
@ -9,6 +11,12 @@ export interface RepositoryMicroagent {
tools: string[];
created_at: string;
git_provider: string;
path: string;
}
export interface IMicroagentItem {
microagent?: RepositoryMicroagent;
conversation?: Conversation;
}
export interface MicroagentFormData {

View File

@ -142,3 +142,68 @@ export const getPR = (isGitLab: boolean) =>
* @returns The short name of the PR
*/
export const getPRShort = (isGitLab: boolean) => (isGitLab ? "MR" : "PR");
/**
* Construct the pull request (merge request) URL for different providers
* @param prNumber The pull request number
* @param provider The git provider
* @param repositoryName The repository name in format "owner/repo"
* @returns The pull request URL
*
* @example
* constructPullRequestUrl(123, "github", "owner/repo") // "https://github.com/owner/repo/pull/123"
* constructPullRequestUrl(456, "gitlab", "owner/repo") // "https://gitlab.com/owner/repo/-/merge_requests/456"
* constructPullRequestUrl(789, "bitbucket", "owner/repo") // "https://bitbucket.org/owner/repo/pull-requests/789"
*/
export const constructPullRequestUrl = (
prNumber: number,
provider: Provider,
repositoryName: string,
): string => {
const baseUrl = getGitProviderBaseUrl(provider);
switch (provider) {
case "github":
return `${baseUrl}/${repositoryName}/pull/${prNumber}`;
case "gitlab":
return `${baseUrl}/${repositoryName}/-/merge_requests/${prNumber}`;
case "bitbucket":
return `${baseUrl}/${repositoryName}/pull-requests/${prNumber}`;
default:
return "";
}
};
/**
* Construct the microagent URL for different providers
* @param gitProvider The git provider
* @param repositoryName The repository name in format "owner/repo"
* @param microagentPath The path to the microagent in the repository
* @returns The URL to the microagent file in the Git provider
*
* @example
* constructMicroagentUrl("github", "owner/repo", ".openhands/microagents/tell-me-a-joke.md")
* // "https://github.com/owner/repo/blob/main/.openhands/microagents/tell-me-a-joke.md"
* constructMicroagentUrl("gitlab", "owner/repo", "microagents/git-helper.md")
* // "https://gitlab.com/owner/repo/-/blob/main/microagents/git-helper.md"
* constructMicroagentUrl("bitbucket", "owner/repo", ".openhands/microagents/docker-helper.md")
* // "https://bitbucket.org/owner/repo/src/main/.openhands/microagents/docker-helper.md"
*/
export const constructMicroagentUrl = (
gitProvider: Provider,
repositoryName: string,
microagentPath: string,
): string => {
const baseUrl = getGitProviderBaseUrl(gitProvider);
switch (gitProvider) {
case "github":
return `${baseUrl}/${repositoryName}/blob/main/${microagentPath}`;
case "gitlab":
return `${baseUrl}/${repositoryName}/-/blob/main/${microagentPath}`;
case "bitbucket":
return `${baseUrl}/${repositoryName}/src/main/${microagentPath}`;
default:
return "";
}
};

View File

@ -254,6 +254,7 @@ class MicroagentResponse(BaseModel):
tools: list[str] = []
created_at: datetime
git_provider: ProviderType
path: str # Path to the microagent in the Git provider (e.g., ".openhands/microagents/tell-me-a-joke")
def _get_file_creation_time(repo_dir: Path, file_path: Path) -> datetime:
@ -453,6 +454,7 @@ def _process_microagents(
),
created_at=created_at,
git_provider=git_provider,
path=str(agent_file_path.relative_to(repo_dir)),
)
)
@ -476,6 +478,7 @@ def _process_microagents(
),
created_at=created_at,
git_provider=git_provider,
path=str(agent_file_path.relative_to(repo_dir)),
)
)

View File

@ -102,7 +102,7 @@ def mock_repo_microagent():
]
),
),
source='test_source',
source='.openhands/microagents/test_repo_agent.md',
type=MicroagentType.REPO_KNOWLEDGE,
)
@ -128,7 +128,7 @@ def mock_knowledge_microagent():
]
),
),
source='test_source',
source='.openhands/microagents/test_knowledge_agent.md',
type=MicroagentType.KNOWLEDGE,
triggers=['test', 'knowledge', 'search'],
)
@ -283,7 +283,72 @@ class TestGetRepositoryMicroagents:
mock_result.stderr = ''
mock_subprocess_run.return_value = mock_result
mock_load_microagents.return_value = mock_microagents_data
# Create mock microagents with proper absolute paths
repo_agent_with_path = RepoMicroagent(
name='test_repo_agent',
content='This is a test repository microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_repo_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[
InputMetadata(
name='query',
type='str',
description='Search query for the repository',
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='git', command='git'),
MCPStdioServerConfig(name='file_editor', command='editor'),
]
),
),
source=str(
Path(temp_microagents_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_repo_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
knowledge_agent_with_path = KnowledgeMicroagent(
name='test_knowledge_agent',
content='This is a test knowledge microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_knowledge_agent',
type=MicroagentType.KNOWLEDGE,
inputs=[
InputMetadata(
name='topic', type='str', description='Topic to search for'
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='search', command='search'),
MCPStdioServerConfig(name='fetch', command='fetch'),
]
),
),
source=str(
Path(temp_microagents_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_knowledge_agent.md'
),
type=MicroagentType.KNOWLEDGE,
triggers=['test', 'knowledge', 'search'],
)
mock_microagents_data_with_paths = (
{'test_repo_agent': repo_agent_with_path},
{'test_knowledge_agent': knowledge_agent_with_path},
)
mock_load_microagents.return_value = mock_microagents_data_with_paths
mock_mkdtemp.return_value = temp_microagents_dir
# Execute test
@ -308,6 +373,8 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in repo_agent
assert 'git_provider' in repo_agent
assert repo_agent['git_provider'] == 'github'
assert 'path' in repo_agent
assert repo_agent['path'] == '.openhands/microagents/test_repo_agent.md'
# Check knowledge microagent
knowledge_agent = next(m for m in data if m['name'] == 'test_knowledge_agent')
@ -323,6 +390,10 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in knowledge_agent
assert 'git_provider' in knowledge_agent
assert knowledge_agent['git_provider'] == 'github'
assert 'path' in knowledge_agent
assert (
knowledge_agent['path'] == '.openhands/microagents/test_knowledge_agent.md'
)
@pytest.mark.asyncio
@patch('openhands.server.routes.git.ProviderHandler')
@ -555,8 +626,38 @@ class TestGetRepositoryMicroagents:
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Mock load_microagents_from_dir
mock_repo_agents = {'test_repo_agent': mock_repo_microagent}
# Create mock microagents with proper absolute paths
repo_agent_with_path = RepoMicroagent(
name='test_repo_agent',
content='This is a test repository microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_repo_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[
InputMetadata(
name='query',
type='str',
description='Search query for the repository',
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='git', command='git'),
MCPStdioServerConfig(name='file_editor', command='editor'),
]
),
),
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_repo_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
mock_repo_agents = {'test_repo_agent': repo_agent_with_path}
mock_knowledge_agents = {}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
@ -574,6 +675,8 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in data[0]
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'github'
assert 'path' in data[0]
assert data[0]['path'] == '.openhands/microagents/test_repo_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@ -634,8 +737,38 @@ class TestGetRepositoryMicroagents:
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Mock load_microagents_from_dir
mock_repo_agents = {'test_repo_agent': mock_repo_microagent}
# Create mock microagents with proper absolute paths
repo_agent_with_path = RepoMicroagent(
name='test_repo_agent',
content='This is a test repository microagent for testing purposes.',
metadata=MicroagentMetadata(
name='test_repo_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[
InputMetadata(
name='query',
type='str',
description='Search query for the repository',
)
],
mcp_tools=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(name='git', command='git'),
MCPStdioServerConfig(name='file_editor', command='editor'),
]
),
),
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'test_repo_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
mock_repo_agents = {'test_repo_agent': repo_agent_with_path}
mock_knowledge_agents = {}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
@ -652,6 +785,8 @@ class TestGetRepositoryMicroagents:
assert 'created_at' in data[0]
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'github'
assert 'path' in data[0]
assert data[0]['path'] == '.openhands/microagents/test_repo_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@ -748,6 +883,33 @@ class TestGetRepositoryMicroagents:
lambda: mock_provider_tokens
)
mock_provider_handler = MagicMock()
mock_repository = Repository(
id='123456',
full_name='test/repo',
git_provider=ProviderType.GITHUB,
is_public=True,
stargazers_count=100,
)
mock_provider_handler.verify_repo_provider = AsyncMock(
return_value=mock_repository
)
mock_provider_handler.get_authenticated_git_url = AsyncMock(
return_value='https://ghp_test_token@github.com/test/repo.git'
)
mock_provider_handler_class.return_value = mock_provider_handler
# Mock subprocess.run for successful clone
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = ''
mock_subprocess_run.return_value = mock_result
# Create temporary directory with microagents
temp_dir = tempfile.mkdtemp()
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create microagent without MCP tools
repo_microagent = RepoMicroagent(
name='simple_agent',
@ -758,10 +920,65 @@ class TestGetRepositoryMicroagents:
inputs=[],
mcp_tools=None,
),
source='test_source',
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'simple_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
# Mock load_microagents_from_dir
mock_repo_agents = {'simple_agent': repo_microagent}
mock_knowledge_agents = {}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
try:
# Execute test
response = test_client.get('/api/user/repository/test/repo/microagents')
# Assertions
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]['name'] == 'simple_agent'
assert data[0]['tools'] == []
assert 'created_at' in data[0]
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'github'
assert 'path' in data[0]
assert data[0]['path'] == '.openhands/microagents/simple_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.mark.asyncio
@patch(
'openhands.server.routes.git._get_file_creation_time',
return_value=datetime.now(),
)
@patch('openhands.server.routes.git.tempfile.mkdtemp')
@patch('openhands.server.routes.git.load_microagents_from_dir')
@patch('openhands.server.routes.git.subprocess.run')
@patch('openhands.server.routes.git.ProviderHandler')
async def test_get_microagents_path_field_variations(
self,
mock_provider_handler_class,
mock_subprocess_run,
mock_load_microagents,
mock_mkdtemp,
mock_get_file_creation_time,
test_client,
mock_provider_tokens,
):
"""Test path field with different microagent file locations and structures."""
# Setup mocks
test_client.app.dependency_overrides[get_provider_tokens] = (
lambda: mock_provider_tokens
)
mock_provider_handler = MagicMock()
mock_repository = Repository(
id='123456',
@ -789,9 +1006,46 @@ class TestGetRepositoryMicroagents:
microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create microagents with different source paths
repo_microagent_deep = RepoMicroagent(
name='deep_agent',
content='Agent in nested directory',
metadata=MicroagentMetadata(
name='deep_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source=str(
Path(temp_dir)
/ 'repo'
/ '.openhands'
/ 'microagents'
/ 'nested'
/ 'deep_agent.md'
),
type=MicroagentType.REPO_KNOWLEDGE,
)
knowledge_microagent_root = KnowledgeMicroagent(
name='root_agent',
content='Agent in root microagents directory',
metadata=MicroagentMetadata(
name='root_agent',
type=MicroagentType.KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source=str(
Path(temp_dir) / 'repo' / '.openhands' / 'microagents' / 'root_agent.md'
),
type=MicroagentType.KNOWLEDGE,
triggers=[],
)
# Mock load_microagents_from_dir
mock_repo_agents = {'simple_agent': repo_microagent}
mock_knowledge_agents = {}
mock_repo_agents = {'deep_agent': repo_microagent_deep}
mock_knowledge_agents = {'root_agent': knowledge_microagent_root}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
@ -802,11 +1056,118 @@ class TestGetRepositoryMicroagents:
# Assertions
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]['name'] == 'simple_agent'
assert data[0]['tools'] == []
assert 'created_at' in data[0]
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'github'
assert len(data) == 2
# Check repo microagent with nested path
repo_agent = next(m for m in data if m['name'] == 'deep_agent')
assert repo_agent['type'] == 'repo'
assert 'path' in repo_agent
assert repo_agent['path'] == '.openhands/microagents/nested/deep_agent.md'
# Check knowledge microagent with root path
knowledge_agent = next(m for m in data if m['name'] == 'root_agent')
assert knowledge_agent['type'] == 'knowledge'
assert 'path' in knowledge_agent
assert knowledge_agent['path'] == '.openhands/microagents/root_agent.md'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.mark.asyncio
@patch(
'openhands.server.routes.git._get_file_creation_time',
return_value=datetime.now(),
)
@patch('openhands.server.routes.git.tempfile.mkdtemp')
@patch('openhands.server.routes.git.load_microagents_from_dir')
@patch('openhands.server.routes.git.subprocess.run')
@patch('openhands.server.routes.git.ProviderHandler')
async def test_get_microagents_path_field_gitlab_structure(
self,
mock_provider_handler_class,
mock_subprocess_run,
mock_load_microagents,
mock_mkdtemp,
mock_get_file_creation_time,
test_client,
mock_provider_tokens,
):
"""Test path field with GitLab repository structure (openhands-config)."""
# Setup mocks with GitLab provider
provider_tokens = MappingProxyType(
{
ProviderType.GITLAB: ProviderToken(
token=SecretStr('glpat_test_token'), host='gitlab.com'
)
}
)
test_client.app.dependency_overrides[get_provider_tokens] = (
lambda: provider_tokens
)
mock_provider_handler = MagicMock()
mock_repository = Repository(
id='123456',
full_name='test/openhands-config',
git_provider=ProviderType.GITLAB,
is_public=True,
stargazers_count=100,
)
mock_provider_handler.verify_repo_provider = AsyncMock(
return_value=mock_repository
)
mock_provider_handler.get_authenticated_git_url = AsyncMock(
return_value='https://glpat_test_token@gitlab.com/test/openhands-config.git'
)
mock_provider_handler_class.return_value = mock_provider_handler
# Mock subprocess.run for successful clone
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = ''
mock_subprocess_run.return_value = mock_result
# Create temporary directory with GitLab structure
temp_dir = tempfile.mkdtemp()
microagents_dir = Path(temp_dir) / 'repo' / 'microagents'
microagents_dir.mkdir(parents=True, exist_ok=True)
# Create microagent for GitLab structure
repo_microagent = RepoMicroagent(
name='gitlab_agent',
content='Agent in GitLab repository',
metadata=MicroagentMetadata(
name='gitlab_agent',
type=MicroagentType.REPO_KNOWLEDGE,
inputs=[],
mcp_tools=None,
),
source=str(Path(temp_dir) / 'repo' / 'microagents' / 'gitlab_agent.md'),
type=MicroagentType.REPO_KNOWLEDGE,
)
# Mock load_microagents_from_dir
mock_repo_agents = {'gitlab_agent': repo_microagent}
mock_knowledge_agents = {}
mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents)
mock_mkdtemp.return_value = temp_dir
try:
# Execute test
response = test_client.get(
'/api/user/repository/test/openhands-config/microagents'
)
# Assertions
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]['name'] == 'gitlab_agent'
assert data[0]['type'] == 'repo'
assert 'path' in data[0]
assert data[0]['path'] == 'microagents/gitlab_agent.md'
assert 'git_provider' in data[0]
assert data[0]['git_provider'] == 'gitlab'
finally:
shutil.rmtree(temp_dir, ignore_errors=True)