fix: subscription logic by polling for available runtime (microagent management, memory UI) (#10519)

Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Hiep Le
2025-08-25 23:44:00 +07:00
committed by GitHub
parent 049f058ed1
commit 7f4d311294
18 changed files with 743 additions and 393 deletions

View File

@@ -14,21 +14,31 @@ import { Conversation } from "#/api/open-hands.types";
// Mock hooks
const mockUseUserProviders = vi.fn();
const mockUseUserRepositories = vi.fn();
const mockUseGitRepositories = vi.fn();
const mockUseConfig = vi.fn();
const mockUseRepositoryMicroagents = vi.fn();
const mockUseSearchConversations = vi.fn();
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => mockUseUserProviders(),
}));
vi.mock("#/hooks/query/use-user-repositories", () => ({
useUserRepositories: () => mockUseUserRepositories(),
vi.mock("#/hooks/query/use-git-repositories", () => ({
useGitRepositories: () => mockUseGitRepositories(),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => mockUseConfig(),
}));
vi.mock("#/hooks/query/use-repository-microagents", () => ({
useRepositoryMicroagents: () => mockUseRepositoryMicroagents(),
}));
vi.mock("#/hooks/query/use-search-conversations", () => ({
useSearchConversations: () => mockUseSearchConversations(),
}));
describe("MicroagentManagement", () => {
const RouterStub = createRoutesStub([
{
@@ -174,7 +184,7 @@ describe("MicroagentManagement", () => {
providers: ["github"],
});
mockUseUserRepositories.mockReturnValue({
mockUseGitRepositories.mockReturnValue({
data: {
pages: [
{
@@ -196,6 +206,18 @@ describe("MicroagentManagement", () => {
},
});
mockUseRepositoryMicroagents.mockReturnValue({
data: mockMicroagents,
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
data: mockConversations,
isLoading: false,
isError: false,
});
// Setup default mock for retrieveUserGitRepositories
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
data: [...mockRepositories],
@@ -227,7 +249,7 @@ describe("MicroagentManagement", () => {
it("should display loading state when fetching repositories", async () => {
// Mock loading state
mockUseUserRepositories.mockReturnValue({
mockUseGitRepositories.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
@@ -245,7 +267,7 @@ describe("MicroagentManagement", () => {
it("should handle error when fetching repositories", async () => {
// Mock error state
mockUseUserRepositories.mockReturnValue({
mockUseGitRepositories.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
@@ -258,7 +280,7 @@ describe("MicroagentManagement", () => {
// Wait for the error to be handled
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
});
@@ -267,7 +289,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Check that tabs are rendered
@@ -285,7 +307,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and rendered
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Check that repository names are displayed
@@ -300,7 +322,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -309,10 +331,7 @@ describe("MicroagentManagement", () => {
// Wait for microagents to be fetched
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
});
// Check that microagents are displayed
@@ -325,19 +344,17 @@ describe("MicroagentManagement", () => {
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
);
mockUseRepositoryMicroagents.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -350,19 +367,17 @@ describe("MicroagentManagement", () => {
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"),
);
mockUseRepositoryMicroagents.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -371,23 +386,23 @@ describe("MicroagentManagement", () => {
// Wait for the error to be handled
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalledWith("user", "repo2");
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
});
});
it("should display empty state when no microagents are found", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
getRepositoryMicroagentsSpy.mockResolvedValue([]);
mockUseRepositoryMicroagents.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -396,7 +411,7 @@ describe("MicroagentManagement", () => {
// Wait for microagents to be fetched
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalledWith("user", "repo2");
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
});
// Check that no microagents are displayed
@@ -410,7 +425,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -419,10 +434,7 @@ describe("MicroagentManagement", () => {
// Wait for microagents to be fetched
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
});
// Check that microagent cards display correct information
@@ -449,7 +461,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and processed
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Wait for repositories to be displayed in the accordion
@@ -468,7 +480,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and processed
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Wait for repositories to be displayed in the accordion
@@ -492,7 +504,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -513,7 +525,7 @@ describe("MicroagentManagement", () => {
it("should display empty state when no repositories are found", async () => {
// Mock empty repositories
mockUseUserRepositories.mockReturnValue({
mockUseGitRepositories.mockReturnValue({
data: {
pages: [
{
@@ -533,7 +545,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Check that empty state messages are displayed
@@ -550,7 +562,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -559,14 +571,11 @@ describe("MicroagentManagement", () => {
// Wait for microagents to be fetched for first repo
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
});
// Check that the API call was made
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledTimes(1);
// Check that the hook was called
expect(mockUseRepositoryMicroagents).toHaveBeenCalledTimes(1);
});
it("should display ready to add microagent message in main area", async () => {
@@ -591,7 +600,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Check that search input is rendered
@@ -611,7 +620,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Initially only repositories with .openhands should be visible
@@ -642,7 +651,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Type in search input with uppercase
@@ -665,7 +674,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Type in search input with partial match
@@ -691,7 +700,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Type in search input
@@ -724,7 +733,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Type in search input with non-existent repository name
@@ -752,7 +761,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Type in search input with special characters
@@ -773,7 +782,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Filter to show only repo2
@@ -788,10 +797,7 @@ describe("MicroagentManagement", () => {
// Wait for microagents to be fetched
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
});
// Check that microagents are displayed
@@ -808,7 +814,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Type in search input with leading/trailing whitespace
@@ -828,7 +834,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
const searchInput = screen.getByRole("textbox", {
@@ -860,7 +866,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -869,15 +875,8 @@ describe("MicroagentManagement", () => {
// Wait for both microagents and conversations to be fetched
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
expect(OpenHands.searchConversations).toHaveBeenCalledWith(
"user/repo2/.openhands",
"microagent_management",
1000,
);
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
});
});
@@ -887,7 +886,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -896,8 +895,8 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalled();
expect(OpenHands.searchConversations).toHaveBeenCalled();
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
});
// Check that microagents are displayed
@@ -917,23 +916,22 @@ describe("MicroagentManagement", () => {
it("should show loading state when both microagents and conversations are loading", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
// Make both queries never resolve
getRepositoryMicroagentsSpy.mockImplementation(
() => new Promise(() => {}),
);
searchConversationsSpy.mockImplementation(() => new Promise(() => {}));
mockUseRepositoryMicroagents.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -950,7 +948,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -959,8 +957,8 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalled();
expect(OpenHands.searchConversations).toHaveBeenCalled();
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
});
// Check that loading spinner is not displayed
@@ -975,7 +973,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -984,8 +982,8 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalled();
expect(OpenHands.searchConversations).toHaveBeenCalled();
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
});
// Check that microagent file paths are displayed for microagents
@@ -1010,21 +1008,22 @@ describe("MicroagentManagement", () => {
it("should show learn this repo component when no microagents and no conversations", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
// Mock both queries to return empty arrays
getRepositoryMicroagentsSpy.mockResolvedValue([]);
searchConversationsSpy.mockResolvedValue([]);
mockUseRepositoryMicroagents.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1033,8 +1032,8 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalled();
expect(searchConversationsSpy).toHaveBeenCalled();
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
});
// Check that the learn this repo component is displayed
@@ -1046,21 +1045,22 @@ describe("MicroagentManagement", () => {
it("should show learn this repo component when only conversations exist but no microagents", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
// Mock microagents to return empty array, conversations to return data
getRepositoryMicroagentsSpy.mockResolvedValue([]);
searchConversationsSpy.mockResolvedValue([...mockConversations]);
mockUseRepositoryMicroagents.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
data: [...mockConversations],
isLoading: false,
isError: false,
});
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1069,8 +1069,8 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalled();
expect(searchConversationsSpy).toHaveBeenCalled();
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
});
// Check that conversations are displayed
@@ -1088,21 +1088,22 @@ describe("MicroagentManagement", () => {
it("should show learn this repo component when only microagents exist but no conversations", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
// Mock microagents to return data, conversations to return empty array
getRepositoryMicroagentsSpy.mockResolvedValue([...mockMicroagents]);
searchConversationsSpy.mockResolvedValue([]);
mockUseRepositoryMicroagents.mockReturnValue({
data: [...mockMicroagents],
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1111,8 +1112,8 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalled();
expect(searchConversationsSpy).toHaveBeenCalled();
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
});
// Check that microagents are displayed
@@ -1130,16 +1131,17 @@ describe("MicroagentManagement", () => {
it("should handle error when fetching conversations", async () => {
const user = userEvent.setup();
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
searchConversationsSpy.mockRejectedValue(
new Error("Failed to fetch conversations"),
);
mockUseSearchConversations.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1148,11 +1150,7 @@ describe("MicroagentManagement", () => {
// Wait for the error to be handled
await waitFor(() => {
expect(searchConversationsSpy).toHaveBeenCalledWith(
"user/repo2/.openhands",
"microagent_management",
1000,
);
expect(mockUseSearchConversations).toHaveBeenCalled();
});
// Check that the learn this repo component is displayed (since conversations failed)
@@ -1163,27 +1161,22 @@ describe("MicroagentManagement", () => {
});
// Also check that the microagents query was called successfully
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
});
it("should handle error when fetching microagents but conversations succeed", async () => {
const user = userEvent.setup();
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
getRepositoryMicroagentsSpy.mockRejectedValue(
new Error("Failed to fetch microagents"),
);
mockUseRepositoryMicroagents.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1192,10 +1185,7 @@ describe("MicroagentManagement", () => {
// Wait for the error to be handled
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalledWith(
"user",
"repo2",
);
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
});
// Check that the learn this repo component is displayed (since microagents failed)
@@ -1207,13 +1197,11 @@ describe("MicroagentManagement", () => {
it("should call searchConversations with correct parameters", async () => {
const user = userEvent.setup();
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1222,11 +1210,7 @@ describe("MicroagentManagement", () => {
// Wait for searchConversations to be called
await waitFor(() => {
expect(searchConversationsSpy).toHaveBeenCalledWith(
"user/repo2/.openhands",
"microagent_management",
1000,
);
expect(mockUseSearchConversations).toHaveBeenCalled();
});
});
@@ -1236,7 +1220,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1245,8 +1229,8 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalled();
expect(OpenHands.searchConversations).toHaveBeenCalled();
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
});
// Check that conversations display correct information
@@ -1263,7 +1247,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1272,15 +1256,8 @@ describe("MicroagentManagement", () => {
// Wait for both queries to be called for first repo
await waitFor(() => {
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
"user",
"repo2",
);
expect(OpenHands.searchConversations).toHaveBeenCalledWith(
"user/repo2/.openhands",
"microagent_management",
1000,
);
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
});
// Check that both microagents and conversations are displayed
@@ -1304,7 +1281,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and processed
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Wait for repositories to be displayed in the accordion
@@ -1325,7 +1302,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and processed
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Wait for repositories to be displayed in the accordion
@@ -1387,7 +1364,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and processed
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Wait for repositories to be displayed in the accordion
@@ -1418,7 +1395,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and processed
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Wait for repositories to be displayed in the accordion
@@ -1448,7 +1425,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and processed
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Wait for repositories to be displayed in the accordion
@@ -1488,7 +1465,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and processed
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Wait for repositories to be displayed in the accordion
@@ -1522,7 +1499,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and processed
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Wait for repositories to be displayed in the accordion
@@ -1555,7 +1532,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and processed
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Wait for repositories to be displayed in the accordion
@@ -2409,19 +2386,22 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Setup mocks before rendering
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
getRepositoryMicroagentsSpy.mockResolvedValue([]);
searchConversationsSpy.mockResolvedValue([]);
mockUseRepositoryMicroagents.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion to expand it
@@ -2430,8 +2410,8 @@ describe("MicroagentManagement", () => {
// Wait for microagents and conversations to be fetched
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalled();
expect(searchConversationsSpy).toHaveBeenCalled();
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
});
// Verify the learn this repo trigger is displayed when no microagents exist
@@ -2451,19 +2431,22 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Setup mocks
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
getRepositoryMicroagentsSpy.mockResolvedValue([]);
searchConversationsSpy.mockResolvedValue([]);
mockUseRepositoryMicroagents.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
renderMicroagentManagement();
// Wait for repositories and expand accordion
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
const repoAccordion = screen.getByTestId("repository-name-tooltip");
@@ -2496,35 +2479,36 @@ describe("MicroagentManagement", () => {
const user = userEvent.setup();
// Setup mocks with existing microagents (should NOT show trigger)
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
// Mock with existing microagent
getRepositoryMicroagentsSpy.mockResolvedValue([
{
name: "test-microagent",
created_at: "2021-10-01",
git_provider: "github",
path: ".openhands/microagents/test",
},
]);
searchConversationsSpy.mockResolvedValue([]);
mockUseRepositoryMicroagents.mockReturnValue({
data: [
{
name: "test-microagent",
created_at: "2021-10-01",
git_provider: "github",
path: ".openhands/microagents/test",
},
],
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
renderMicroagentManagement();
await waitFor(() => {
expect(mockUseUserRepositories).toHaveBeenCalled();
expect(mockUseGitRepositories).toHaveBeenCalled();
});
const repoAccordion = screen.getByTestId("repository-name-tooltip");
await user.click(repoAccordion);
await waitFor(() => {
expect(getRepositoryMicroagentsSpy).toHaveBeenCalled();
expect(searchConversationsSpy).toHaveBeenCalled();
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
});
// Should NOT show the learn this repo trigger when microagents exist

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { StylesConfig } from "react-select";
import { Provider } from "../../types/settings";
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
@@ -11,6 +12,8 @@ export interface GitProviderDropdownProps {
disabled?: boolean;
isLoading?: boolean;
onChange?: (provider: Provider | null) => void;
classNamePrefix?: string;
styles?: StylesConfig<SelectOption, false>;
}
export function GitProviderDropdown({
@@ -22,6 +25,8 @@ export function GitProviderDropdown({
disabled = false,
isLoading = false,
onChange,
classNamePrefix,
styles,
}: GitProviderDropdownProps) {
const options: SelectOption[] = useMemo(
() =>
@@ -53,6 +58,8 @@ export function GitProviderDropdown({
isSearchable={false}
isLoading={isLoading}
onChange={handleChange}
classNamePrefix={classNamePrefix}
styles={styles}
/>
);
}

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import Select from "react-select";
import Select, { StylesConfig } from "react-select";
import { cn } from "#/utils/utils";
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
@@ -17,6 +17,8 @@ export interface ReactSelectDropdownProps {
isSearchable?: boolean;
isLoading?: boolean;
onChange?: (option: SelectOption | null) => void;
classNamePrefix?: string;
styles?: StylesConfig<SelectOption, false>;
}
export function ReactSelectDropdown({
@@ -31,6 +33,8 @@ export function ReactSelectDropdown({
isSearchable = true,
isLoading = false,
onChange,
classNamePrefix,
styles,
}: ReactSelectDropdownProps) {
const customStyles = useMemo(() => getCustomStyles<SelectOption>(), []);
@@ -46,8 +50,9 @@ export function ReactSelectDropdown({
isSearchable={isSearchable}
isLoading={isLoading}
onChange={onChange}
styles={customStyles}
styles={styles || customStyles}
className="w-full"
classNamePrefix={classNamePrefix}
/>
{errorMessage && (
<p className="text-red-500 text-sm mt-1">{errorMessage}</p>

View File

@@ -90,3 +90,26 @@ export const getCustomStyles = <T extends SelectOptionBase>(): StylesConfig<
color: "#B7BDC2", // tertiary-light
}),
});
export const getGitProviderMicroagentManagementCustomStyles = <
T extends SelectOptionBase,
>(): StylesConfig<T, false> => ({
...getCustomStyles<T>(),
control: (provided, state) => ({
...provided,
backgroundColor: state.isDisabled ? "#363636" : "#454545", // darker tertiary when disabled
border: "1px solid #717888",
borderRadius: "0.125rem",
minHeight: "2.5rem",
padding: "0 0.5rem",
boxShadow: "none",
opacity: state.isDisabled ? 0.6 : 1,
cursor: state.isDisabled ? "not-allowed" : "pointer",
"&:hover": {
borderColor: "#717888",
},
"& .git-provider-dropdown__value-container": {
padding: "2px 0",
},
}),
});

View File

@@ -24,6 +24,17 @@ import { AgentState } from "#/types/agent-state";
import { getFirstPRUrl } from "#/utils/parse-pr-url";
import MemoryIcon from "#/icons/memory_icon.svg?react";
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
interface MessagesProps {
messages: (OpenHandsAction | OpenHandsObservation)[];
isAwaitingUserConfirmation: boolean;
@@ -31,8 +42,11 @@ interface MessagesProps {
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) => {
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
const {
createConversationAndSubscribe,
isPending,
unsubscribeFromConversation,
} = useCreateConversationAndSubscribeMultiple();
const { getOptimisticUserMessage } = useOptimisticUserMessage();
const { conversationId } = useConversationId();
const { data: conversation } = useUserConversation(conversationId);
@@ -93,20 +107,6 @@ export const Messages: React.FC<MessagesProps> = React.memo(
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown, microagentConversationId: string) => {
// Handle error events
const isErrorEvent = (
evt: unknown,
): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
@@ -119,7 +119,11 @@ export const Messages: React.FC<MessagesProps> = React.memo(
isOpenHandsEvent(socketEvent) &&
isAgentStateChangeObservation(socketEvent)
) {
if (socketEvent.extras.agent_state === AgentState.FINISHED) {
// Handle completion states
if (
socketEvent.extras.agent_state === AgentState.FINISHED ||
socketEvent.extras.agent_state === AgentState.AWAITING_USER_INPUT
) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
@@ -127,6 +131,8 @@ export const Messages: React.FC<MessagesProps> = React.memo(
: statusEntry,
),
);
unsubscribeFromConversation(microagentConversationId);
}
} else if (
isOpenHandsEvent(socketEvent) &&
@@ -147,9 +153,27 @@ export const Messages: React.FC<MessagesProps> = React.memo(
),
);
}
unsubscribeFromConversation(microagentConversationId);
} else {
// For any other event, transition from WAITING to CREATING if still waiting
setMicroagentStatuses((prev) => {
const currentStatus = prev.find(
(entry) => entry.conversationId === microagentConversationId,
)?.status;
if (currentStatus === MicroagentStatus.WAITING) {
return prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? { ...statusEntry, status: MicroagentStatus.CREATING }
: statusEntry,
);
}
return prev; // No change needed
});
}
},
[setMicroagentStatuses],
[setMicroagentStatuses, unsubscribeFromConversation],
);
const handleLaunchMicroagent = (
@@ -178,13 +202,13 @@ export const Messages: React.FC<MessagesProps> = React.memo(
},
onSuccessCallback: (newConversationId: string) => {
setShowLaunchMicroagentModal(false);
// Update status with conversation ID
// Update status with conversation ID - start with WAITING
setMicroagentStatuses((prev) => [
...prev.filter((status) => status.eventId !== selectedEventId),
{
eventId: selectedEventId,
conversationId: newConversationId,
status: MicroagentStatus.CREATING,
status: MicroagentStatus.WAITING,
},
]);
},

View File

@@ -19,6 +19,8 @@ export function MicroagentStatusIndicator({
const getStatusText = () => {
switch (status) {
case MicroagentStatus.WAITING:
return t("MICROAGENT$STATUS_WAITING");
case MicroagentStatus.CREATING:
return t("MICROAGENT$STATUS_CREATING");
case MicroagentStatus.COMPLETED:
@@ -35,6 +37,8 @@ export function MicroagentStatusIndicator({
const getStatusIcon = () => {
switch (status) {
case MicroagentStatus.WAITING:
return <Spinner size="sm" />;
case MicroagentStatus.CREATING:
return <Spinner size="sm" />;
case MicroagentStatus.COMPLETED:

View File

@@ -10,6 +10,11 @@ interface ConversationCreatedToastProps {
onClose: () => void;
}
interface ConversationStartingToastProps {
conversationId: string;
onClose: () => void;
}
function ConversationCreatedToast({
conversationId,
onClose,
@@ -37,6 +42,33 @@ function ConversationCreatedToast({
);
}
function ConversationStartingToast({
conversationId,
onClose,
}: ConversationStartingToastProps) {
const { t } = useTranslation();
return (
<div className="flex items-start gap-2">
<Spinner size="sm" />
<div>
{t("MICROAGENT$CONVERSATION_STARTING")}
<br />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t("MICROAGENT$VIEW_CONVERSATION")}
</a>
</div>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
);
}
interface ConversationFinishedToastProps {
conversationId: string;
onClose: () => void;
@@ -78,10 +110,18 @@ function ConversationErroredToast({
errorMessage,
onClose,
}: ConversationErroredToastProps) {
const { t } = useTranslation();
// Check if the error message is a translation key
const displayMessage =
errorMessage === "MICROAGENT$UNKNOWN_ERROR"
? t(errorMessage)
: errorMessage;
return (
<div className="flex items-start gap-2">
<SuccessIndicator status="error" />
<div>{errorMessage}</div>
<div>{displayMessage}</div>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
@@ -136,3 +176,18 @@ export const renderConversationErroredToast = (
duration: 5000,
},
);
export const renderConversationStartingToast = (conversationId: string) =>
toast(
(toastInstance) => (
<ConversationStartingToast
conversationId={conversationId}
onClose={() => toast.dismiss(toastInstance.id)}
/>
),
{
...TOAST_OPTIONS,
id: `starting-${conversationId}`,
duration: 10000, // Show for 10 seconds or until dismissed
},
);

View File

@@ -17,7 +17,7 @@ export function MicroagentManagementAccordionTitle({
<TooltipButton
tooltip={repository.full_name}
ariaLabel={repository.full_name}
className="text-white text-base font-normal bg-transparent p-0 min-w-0 h-auto cursor-pointer truncate max-w-[200px] translate-y-[-1px]"
className="text-white text-base font-normal bg-transparent p-0 min-w-0 h-auto cursor-pointer truncate max-w-[194px] translate-y-[-1px]"
testId="repository-name-tooltip"
placement="bottom"
>

View File

@@ -32,6 +32,7 @@ import {
} from "#/utils/custom-toast-handlers";
import { getFirstPRUrl } from "#/utils/parse-pr-url";
import { I18nKey } from "#/i18n/declaration";
import { useUserProviders } from "#/hooks/use-user-providers";
// Handle error events
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
@@ -65,16 +66,10 @@ const getConversationInstructions = (
gitProvider: Provider,
) => `Create a microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered).
- This is the instructions about what the microagent should do: ${formData.query}
${
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered). This is the instructions about what the microagent should do: ${formData.query}. ${
formData.triggers && formData.triggers.length > 0
? `
- This is the triggers of the microagent: ${formData.triggers.join(", ")}
`
: "- Please be noted that the microagent doesn't have any triggers."
? `This is the triggers of the microagent: ${formData.triggers.join(", ")}`
: "Please be noted that the microagent doesn't have any triggers."
}
- Step 2: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
@@ -91,16 +86,10 @@ const getUpdateConversationInstructions = (
) => `Update the microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Update the microagent. This is the path of the microagent: ${formData.microagentPath} (The updated microagent must be in the .openhands/microagents folder and should be able to perform the described task when triggered).
- This is the updated instructions about what the microagent should do: ${formData.query}
${
- Step 1: Update the microagent. This is the path of the microagent: ${formData.microagentPath} (The updated microagent must be in the .openhands/microagents folder and should be able to perform the described task when triggered). This is the updated instructions about what the microagent should do: ${formData.query}. ${
formData.triggers && formData.triggers.length > 0
? `
- This is the triggers of the microagent: ${formData.triggers.join(", ")}
`
: "- Please be noted that the microagent doesn't have any triggers."
? `This is the triggers of the microagent: ${formData.triggers.join(", ")}`
: "Please be noted that the microagent doesn't have any triggers."
}
- Step 2: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
@@ -119,6 +108,8 @@ export function MicroagentManagementContent() {
learnThisRepoModalVisible,
} = useSelector((state: RootState) => state.microagentManagement);
const { providers } = useUserProviders();
const { t } = useTranslation();
const dispatch = useDispatch();
@@ -182,11 +173,7 @@ export function MicroagentManagementContent() {
// Check if agent has finished and we have a PR
if (isOpenHandsEvent(socketEvent) && isFinishAction(socketEvent)) {
const prUrl = getFirstPRUrl(socketEvent.args.final_thought || "");
if (prUrl) {
displaySuccessToast(
t(I18nKey.MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW),
);
} else {
if (!prUrl) {
// Agent finished but no PR found
displaySuccessToast(t(I18nKey.MICROAGENT_MANAGEMENT$PR_NOT_CREATED));
}
@@ -329,11 +316,18 @@ export function MicroagentManagementContent() {
</>
);
const providersAreSet = providers.length > 0;
if (width < 1024) {
return (
<div className="w-full h-full flex flex-col gap-6">
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] max-h-[494px] min-h-[494px]">
<MicroagentManagementSidebar isSmallerScreen />
{providersAreSet && (
<MicroagentManagementSidebar
isSmallerScreen
providers={providers}
/>
)}
</div>
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] flex-1 min-h-[494px]">
<MicroagentManagementMain />
@@ -345,7 +339,7 @@ export function MicroagentManagementContent() {
return (
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E] overflow-hidden">
<MicroagentManagementSidebar />
{providersAreSet && <MicroagentManagementSidebar providers={providers} />}
<div className="flex-1">
<MicroagentManagementMain />
</div>

View File

@@ -59,8 +59,10 @@ export function MicroagentManagementMicroagentCard({
if (runtimeStatus === "STATUS$ERROR") {
return t(I18nKey.MICROAGENT$STATUS_ERROR);
}
if (conversationStatus === "RUNNING" && runtimeStatus === "STATUS$READY") {
return t(I18nKey.MICROAGENT$STATUS_OPENING_PR);
if (conversationStatus === "RUNNING") {
return runtimeStatus === "STATUS$READY"
? t(I18nKey.MICROAGENT$STATUS_OPENING_PR)
: t(I18nKey.COMMON$STARTING);
}
return "";
}, [conversationStatus, runtimeStatus, t, hasPr]);

View File

@@ -1,15 +1,12 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Accordion, AccordionItem } from "@heroui/react";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { GitRepository } from "#/types/git";
import { cn } from "#/utils/utils";
import { TabType } from "#/types/microagent-management";
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
import { I18nKey } from "#/i18n/declaration";
import { DOCUMENTATION_URL } from "#/utils/constants";
import { MicroagentManagementAccordionTitle } from "./microagent-management-accordion-title";
import { sanitizeQuery } from "#/utils/sanitize-query";
type MicroagentManagementRepositoriesProps = {
repositories: GitRepository[];
@@ -21,23 +18,9 @@ export function MicroagentManagementRepositories({
tabType,
}: MicroagentManagementRepositoriesProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const numberOfRepoMicroagents = repositories.length;
// Filter repositories based on search query
const filteredRepositories = useMemo(() => {
if (!searchQuery.trim()) {
return repositories;
}
const sanitizedQuery = sanitizeQuery(searchQuery);
return repositories.filter((repository) => {
const sanitizedRepoName = sanitizeQuery(repository.full_name);
return sanitizedRepoName.includes(sanitizedQuery);
});
}, [repositories, searchQuery]);
if (numberOfRepoMicroagents === 0) {
if (tabType === "personal") {
return (
@@ -73,25 +56,6 @@ export function MicroagentManagementRepositories({
return (
<div className="flex flex-col gap-4 w-full">
{/* Search Input */}
<div className="flex flex-col gap-2 w-full">
<label htmlFor="repository-search" className="sr-only">
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
</label>
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</div>
{/* Repositories Accordion */}
<Accordion
variant="splitted"
@@ -104,7 +68,7 @@ export function MicroagentManagementRepositories({
}}
selectionMode="multiple"
>
{filteredRepositories.map((repository) => (
{repositories.map((repository) => (
<AccordionItem
key={repository.id}
aria-label={repository.full_name}

View File

@@ -1,59 +1,109 @@
import { useEffect } from "react";
import { useEffect, useState, useMemo } from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useGitRepositories } from "#/hooks/query/use-git-repositories";
import { GitProviderDropdown } from "#/components/common/git-provider-dropdown";
import {
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
} from "#/state/microagent-management-slice";
import { GitRepository } from "#/types/git";
import { Provider } from "#/types/settings";
import { cn } from "#/utils/utils";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { I18nKey } from "#/i18n/declaration";
import { getGitProviderMicroagentManagementCustomStyles } from "#/components/common/react-select-styles";
interface MicroagentManagementSidebarProps {
isSmallerScreen?: boolean;
providers: Provider[];
}
export function MicroagentManagementSidebar({
isSmallerScreen = false,
providers,
}: MicroagentManagementSidebarProps) {
const [selectedProvider, setSelectedProvider] = useState<Provider | null>(
providers.length > 0 ? providers[0] : null,
);
const [searchQuery, setSearchQuery] = useState("");
const dispatch = useDispatch();
const { t } = useTranslation();
const { providers } = useUserProviders();
const selectedProvider = providers.length > 0 ? providers[0] : null;
const { data: repositories, isLoading } =
useUserRepositories(selectedProvider);
const { data: repositories, isLoading } = useGitRepositories({
provider: selectedProvider,
pageSize: 200,
enabled: !!selectedProvider,
});
// Auto-select provider if there's only one
useEffect(() => {
if (providers.length > 0 && !selectedProvider) {
setSelectedProvider(providers[0]);
}
}, [providers, selectedProvider]);
const handleProviderChange = (provider: Provider | null) => {
setSelectedProvider(provider);
setSearchQuery("");
};
// Filter repositories based on search query
const filteredRepositories = useMemo(() => {
if (!repositories?.pages) return null;
// Flatten all pages to get all repositories
const allRepositories = repositories.pages.flatMap((page) => page.data);
if (!searchQuery.trim()) {
return allRepositories;
}
const sanitizedQuery = sanitizeQuery(searchQuery);
return allRepositories.filter((repository: GitRepository) => {
const sanitizedRepoName = sanitizeQuery(repository.full_name);
return sanitizedRepoName.includes(sanitizedQuery);
});
}, [repositories, searchQuery, selectedProvider]);
useEffect(() => {
if (repositories?.pages) {
const personalRepos: GitRepository[] = [];
const organizationRepos: GitRepository[] = [];
const otherRepos: GitRepository[] = [];
// Flatten all pages to get all repositories
const allRepositories = repositories.pages.flatMap((page) => page.data);
allRepositories.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));
if (!filteredRepositories?.length) {
dispatch(setPersonalRepositories([]));
dispatch(setOrganizationRepositories([]));
dispatch(setRepositories([]));
return;
}
}, [repositories, dispatch]);
const personalRepos: GitRepository[] = [];
const organizationRepos: GitRepository[] = [];
const otherRepos: GitRepository[] = [];
filteredRepositories.forEach((repo: GitRepository) => {
const hasOpenHandsSuffix =
selectedProvider === "gitlab"
? repo.full_name.endsWith("/openhands-config")
: 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));
}, [filteredRepositories, selectedProvider, dispatch]);
return (
<div
@@ -63,6 +113,41 @@ export function MicroagentManagementSidebar({
)}
>
<MicroagentManagementSidebarHeader />
{/* Provider Selection */}
{providers.length > 1 && (
<div className="mt-6">
<GitProviderDropdown
providers={providers}
value={selectedProvider}
placeholder="Select Provider"
onChange={handleProviderChange}
className="w-full"
classNamePrefix="git-provider-dropdown"
styles={getGitProviderMicroagentManagementCustomStyles()}
/>
</div>
)}
{/* Search Input */}
<div className="flex flex-col gap-2 w-full mt-6">
<label htmlFor="repository-search" className="sr-only">
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
</label>
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed h-10 box-shadow-none outline-none",
)}
/>
</div>
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 flex-1">
<Spinner size="sm" />

View File

@@ -95,10 +95,10 @@ export function ConversationSubscriptionsProvider({
[],
);
const unsubscribeFromConversation = useCallback(
(conversationId: string) => {
// Get a local reference to the socket data to avoid race conditions
const socketData = conversationSockets[conversationId];
const unsubscribeFromConversation = useCallback((conversationId: string) => {
// Use functional update to access current socket data and perform cleanup
setConversationSockets((prev) => {
const socketData = prev[conversationId];
if (socketData) {
const { socket } = socketData;
@@ -112,24 +112,23 @@ export function ConversationSubscriptionsProvider({
socket.disconnect();
}
// Update state to remove the socket
setConversationSockets((prev) => {
const newSockets = { ...prev };
delete newSockets[conversationId];
return newSockets;
});
// Remove from active IDs
setActiveConversationIds((prev) =>
prev.filter((id) => id !== conversationId),
);
// Clean up event handler reference
delete eventHandlersRef.current[conversationId];
// Remove the socket from state
const newSockets = { ...prev };
delete newSockets[conversationId];
return newSockets;
}
},
[conversationSockets],
);
return prev; // No change if socket not found
});
// Remove from active IDs
setActiveConversationIds((prev) =>
prev.filter((id) => id !== conversationId),
);
}, []);
const subscribeToConversation = useCallback(
(options: {
@@ -173,9 +172,7 @@ export function ConversationSubscriptionsProvider({
if (isErrorEvent(event) || isAgentStatusError(event)) {
renderConversationErroredToast(
conversationId,
isErrorEvent(event)
? event.message
: "Unknown error, please try again",
isErrorEvent(event) ? event.message : "MICROAGENT$UNKNOWN_ERROR",
);
} else if (isStatusUpdate(event)) {
if (event.type === "info" && event.id === "STATUS$STARTING_RUNTIME") {

View File

@@ -1,14 +1,26 @@
import React from "react";
import { useQueries, type Query } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { AxiosError } from "axios";
import { useCreateConversation } from "./mutation/use-create-conversation";
import { useUserProviders } from "./use-user-providers";
import { useConversationSubscriptions } from "#/context/conversation-subscriptions-provider";
import { Provider } from "#/types/settings";
import { CreateMicroagent } from "#/api/open-hands.types";
import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { renderConversationStartingToast } from "#/components/features/chat/microagent/microagent-status-toast";
interface ConversationData {
conversationId: string;
sessionApiKey: string | null;
baseUrl: string;
onEventCallback?: (event: unknown, conversationId: string) => void;
}
/**
* Custom hook to create a conversation and subscribe to it, supporting multiple subscriptions.
* This extends the functionality of useCreateConversationAndSubscribe to allow subscribing to
* multiple conversations simultaneously.
* This version waits for conversation status to be "RUNNING" before establishing WebSocket connection.
* Shows immediate toast feedback and polls conversation status until ready.
*/
export const useCreateConversationAndSubscribeMultiple = () => {
const { mutate: createConversation, isPending } = useCreateConversation();
@@ -20,6 +32,87 @@ export const useCreateConversationAndSubscribeMultiple = () => {
activeConversationIds,
} = useConversationSubscriptions();
// Store conversation data immediately after creation
const [createdConversations, setCreatedConversations] = React.useState<
Record<string, ConversationData>
>({});
// Get conversation IDs that need polling
const conversationIdsToWatch = Object.keys(createdConversations);
// Poll each conversation until it's ready
const conversationQueries = useQueries({
queries: conversationIdsToWatch.map((conversationId) => ({
queryKey: ["conversation-ready-poll", conversationId],
queryFn: () => OpenHands.getConversation(conversationId),
enabled: !!conversationId,
refetchInterval: (query: Query<Conversation | null, AxiosError>) => {
const status = query.state.data?.status;
if (status === "STARTING") {
return 3000; // Poll every 3 seconds while STARTING
}
return false; // Stop polling once not STARTING
},
retry: false,
})),
});
// Extract stable values from queries for dependency array
const queryStatuses = conversationQueries.map((query) => query.data?.status);
const queryDataExists = conversationQueries.map((query) => !!query.data);
// Effect to handle subscription when conversations are ready
React.useEffect(() => {
conversationQueries.forEach((query, index) => {
const conversationId = conversationIdsToWatch[index];
const conversationData = createdConversations[conversationId];
if (!query.data || !conversationData) return;
const { status, url, session_api_key: sessionApiKey } = query.data;
let { baseUrl } = conversationData;
if (url && !url.startsWith("/")) {
baseUrl = new URL(url).host;
}
if (status === "RUNNING") {
// Conversation is ready - subscribe to WebSocket
subscribeToConversation({
conversationId,
sessionApiKey,
providersSet: providers,
baseUrl,
onEvent: conversationData.onEventCallback,
});
// Remove from created conversations (cleanup)
setCreatedConversations((prev) => {
const newCreated = { ...prev };
delete newCreated[conversationId];
return newCreated;
});
} else if (status === "STOPPED") {
// Dismiss the starting toast
toast.dismiss(`starting-${conversationId}`);
// Remove from created conversations (cleanup)
setCreatedConversations((prev) => {
const newCreated = { ...prev };
delete newCreated[conversationId];
return newCreated;
});
}
});
}, [
queryStatuses,
queryDataExists,
conversationIdsToWatch,
createdConversations,
subscribeToConversation,
providers,
]);
const createConversationAndSubscribe = React.useCallback(
({
query,
@@ -49,6 +142,15 @@ export const useCreateConversationAndSubscribeMultiple = () => {
},
{
onSuccess: (data) => {
// Show immediate toast to let user know something is happening
renderConversationStartingToast(data.conversation_id);
// Call the success callback immediately
if (onSuccessCallback) {
onSuccessCallback(data.conversation_id);
}
// Only handle immediate post-creation tasks here
let baseUrl = "";
if (data?.url && !data.url.startsWith("/")) {
baseUrl = new URL(data.url).host;
@@ -58,24 +160,21 @@ export const useCreateConversationAndSubscribeMultiple = () => {
window?.location.host;
}
// Subscribe to the conversation
subscribeToConversation({
conversationId: data.conversation_id,
sessionApiKey: data.session_api_key,
providersSet: providers,
baseUrl,
onEvent: onEventCallback,
});
// Call the success callback if provided
if (onSuccessCallback) {
onSuccessCallback(data.conversation_id);
}
// Store conversation data for polling and eventual subscription
setCreatedConversations((prev) => ({
...prev,
[data.conversation_id]: {
conversationId: data.conversation_id,
sessionApiKey: data.session_api_key,
baseUrl,
onEventCallback,
},
}));
},
},
);
},
[createConversation, subscribeToConversation, providers],
[createConversation],
);
return {

View File

@@ -814,6 +814,9 @@ export enum I18nKey {
MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW = "MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW",
MICROAGENT_MANAGEMENT$PR_NOT_CREATED = "MICROAGENT_MANAGEMENT$PR_NOT_CREATED",
MICROAGENT_MANAGEMENT$ERROR_CREATING_MICROAGENT = "MICROAGENT_MANAGEMENT$ERROR_CREATING_MICROAGENT",
MICROAGENT$STATUS_WAITING = "MICROAGENT$STATUS_WAITING",
MICROAGENT$UNKNOWN_ERROR = "MICROAGENT$UNKNOWN_ERROR",
MICROAGENT$CONVERSATION_STARTING = "MICROAGENT$CONVERSATION_STARTING",
SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT = "SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT",
SETTINGS$SECURITY_ANALYZER_NONE = "SETTINGS$SECURITY_ANALYZER_NONE",
SETTINGS$SECURITY_ANALYZER_INVARIANT = "SETTINGS$SECURITY_ANALYZER_INVARIANT",

View File

@@ -13023,6 +13023,54 @@
"de": "Etwas ist schiefgelaufen. Versuchen Sie, den Microagenten erneut zu starten.",
"uk": "Щось пішло не так. Спробуйте ініціювати мікроагента ще раз."
},
"MICROAGENT$STATUS_WAITING": {
"en": "Waiting for runtime to start...",
"ja": "ランタイムの開始を待機中...",
"zh-CN": "等待运行时启动...",
"zh-TW": "等待運行時啟動...",
"ko-KR": "런타임 시작을 기다리는 중...",
"no": "Venter på at runtime skal starte...",
"it": "In attesa dell'avvio del runtime...",
"pt": "Aguardando o runtime iniciar...",
"es": "Esperando que inicie el runtime...",
"ar": "في انتظار بدء وقت التشغيل...",
"fr": "En attente du démarrage du runtime...",
"tr": "Çalışma zamanının başlaması bekleniyor...",
"de": "Warten auf den Start der Laufzeit...",
"uk": "Очікування запуску середовища виконання..."
},
"MICROAGENT$UNKNOWN_ERROR": {
"en": "Unknown error, please try again",
"ja": "不明なエラーです。もう一度お試しください",
"zh-CN": "未知错误,请重试",
"zh-TW": "未知錯誤,請重試",
"ko-KR": "알 수 없는 오류입니다. 다시 시도해 주세요",
"no": "Ukjent feil, vennligst prøv igjen",
"it": "Errore sconosciuto, riprova",
"pt": "Erro desconhecido, tente novamente",
"es": "Error desconocido, inténtalo de nuevo",
"ar": "خطأ غير معروف، يرجى المحاولة مرة أخرى",
"fr": "Erreur inconnue, veuillez réessayer",
"tr": "Bilinmeyen hata, lütfen tekrar deneyin",
"de": "Unbekannter Fehler, bitte versuchen Sie es erneut",
"uk": "Невідома помилка, спробуйте ще раз"
},
"MICROAGENT$CONVERSATION_STARTING": {
"en": "Starting conversation...",
"ja": "会話を開始しています...",
"zh-CN": "正在开始对话...",
"zh-TW": "正在開始對話...",
"ko-KR": "대화를 시작하는 중...",
"no": "Starter samtale...",
"it": "Avvio conversazione...",
"pt": "Iniciando conversa...",
"es": "Iniciando conversación...",
"ar": "بدء المحادثة...",
"fr": "Démarrage de la conversation...",
"tr": "Konuşma başlatılıyor...",
"de": "Gespräch wird gestartet...",
"uk": "Розпочинається розмова..."
},
"SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT": {
"en": "LLM Analyzer (Default)",
"ja": "LLMアナライザーデフォルト",

View File

@@ -1,4 +1,5 @@
export enum MicroagentStatus {
WAITING = "waiting",
CREATING = "creating",
COMPLETED = "completed",
ERROR = "error",

View File

@@ -24,6 +24,7 @@ from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.runtime import get_runtime_cls
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.server.config.server_config import ServerConfig
from openhands.server.constants import ROOM_KEY
from openhands.server.conversation_manager.conversation_manager import (
@@ -137,9 +138,11 @@ class DockerNestedConversationManager(ConversationManager):
user_id=user_id,
session_api_key=session_api_key,
),
status=ConversationStatus.STARTING
if sid in self._starting_conversation_ids
else ConversationStatus.RUNNING,
status=(
ConversationStatus.STARTING
if sid in self._starting_conversation_ids
else ConversationStatus.RUNNING
),
)
async def _start_agent_loop(
@@ -248,7 +251,11 @@ class DockerNestedConversationManager(ConversationManager):
response.raise_for_status()
init_conversation: dict[str, Any] = {
'initial_user_msg': initial_user_msg,
'initial_user_msg': (
initial_user_msg.content
if initial_user_msg and initial_user_msg.content
else None
),
'image_urls': [],
'replay_json': replay_json,
'conversation_id': sid,
@@ -335,6 +342,45 @@ class DockerNestedConversationManager(ConversationManager):
)
container.stop()
async def _get_runtime_status_from_nested_runtime(
self, conversation_id: str, nested_url: str
) -> RuntimeStatus | None:
"""Get runtime status from the nested runtime via API call.
Args:
conversation_id: The conversation ID to query
nested_url: The base URL of the nested runtime
Returns:
The runtime status if available, None otherwise
"""
try:
async with httpx.AsyncClient(
headers={
'X-Session-API-Key': self._get_session_api_key_for_conversation(
conversation_id
)
}
) as client:
# Query the nested runtime for conversation info
response = await client.get(nested_url)
if response.status_code == 200:
conversation_data = response.json()
runtime_status_str = conversation_data.get('runtime_status')
if runtime_status_str:
# Convert string back to RuntimeStatus enum
return RuntimeStatus(runtime_status_str)
else:
logger.debug(
f'Failed to get conversation info for {conversation_id}: {response.status_code}'
)
except ValueError:
logger.debug(f'Invalid runtime status value: {runtime_status_str}')
except Exception as e:
logger.debug(f'Could not get runtime status for {conversation_id}: {e}')
return None
async def get_agent_loop_info(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> list[AgentLoopInfo]:
@@ -355,6 +401,12 @@ class DockerNestedConversationManager(ConversationManager):
self.config.sandbox.local_runtime_url,
os.getenv('NESTED_RUNTIME_BROWSER_HOST', ''),
)
# Get runtime status from nested runtime
runtime_status = await self._get_runtime_status_from_nested_runtime(
conversation_id, nested_url
)
agent_loop_info = AgentLoopInfo(
conversation_id=conversation_id,
url=nested_url,
@@ -366,9 +418,12 @@ class DockerNestedConversationManager(ConversationManager):
sid=conversation_id,
user_id=user_id,
),
status=ConversationStatus.STARTING
if conversation_id in self._starting_conversation_ids
else ConversationStatus.RUNNING,
status=(
ConversationStatus.STARTING
if conversation_id in self._starting_conversation_ids
else ConversationStatus.RUNNING
),
runtime_status=runtime_status,
)
results.append(agent_loop_info)
return results