diff --git a/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx b/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx index 9fd6bf2277..d4ed43897d 100644 --- a/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx +++ b/frontend/__tests__/components/features/microagent-management/microagent-management.test.tsx @@ -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 diff --git a/frontend/src/components/common/git-provider-dropdown.tsx b/frontend/src/components/common/git-provider-dropdown.tsx index 366634ee3a..a488d11120 100644 --- a/frontend/src/components/common/git-provider-dropdown.tsx +++ b/frontend/src/components/common/git-provider-dropdown.tsx @@ -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; } 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} /> ); } diff --git a/frontend/src/components/common/react-select-dropdown.tsx b/frontend/src/components/common/react-select-dropdown.tsx index f41c2b147a..1b033a5875 100644 --- a/frontend/src/components/common/react-select-dropdown.tsx +++ b/frontend/src/components/common/react-select-dropdown.tsx @@ -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; } export function ReactSelectDropdown({ @@ -31,6 +33,8 @@ export function ReactSelectDropdown({ isSearchable = true, isLoading = false, onChange, + classNamePrefix, + styles, }: ReactSelectDropdownProps) { const customStyles = useMemo(() => getCustomStyles(), []); @@ -46,8 +50,9 @@ export function ReactSelectDropdown({ isSearchable={isSearchable} isLoading={isLoading} onChange={onChange} - styles={customStyles} + styles={styles || customStyles} className="w-full" + classNamePrefix={classNamePrefix} /> {errorMessage && (

{errorMessage}

diff --git a/frontend/src/components/common/react-select-styles.ts b/frontend/src/components/common/react-select-styles.ts index 0b559f2774..de64094742 100644 --- a/frontend/src/components/common/react-select-styles.ts +++ b/frontend/src/components/common/react-select-styles.ts @@ -90,3 +90,26 @@ export const getCustomStyles = (): StylesConfig< color: "#B7BDC2", // tertiary-light }), }); + +export const getGitProviderMicroagentManagementCustomStyles = < + T extends SelectOptionBase, +>(): StylesConfig => ({ + ...getCustomStyles(), + 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", + }, + }), +}); diff --git a/frontend/src/components/features/chat/messages.tsx b/frontend/src/components/features/chat/messages.tsx index ecfc7546d6..c5111506de 100644 --- a/frontend/src/components/features/chat/messages.tsx +++ b/frontend/src/components/features/chat/messages.tsx @@ -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 = 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 = 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 = 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 = React.memo( : statusEntry, ), ); + + unsubscribeFromConversation(microagentConversationId); } } else if ( isOpenHandsEvent(socketEvent) && @@ -147,9 +153,27 @@ export const Messages: React.FC = 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 = 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, }, ]); }, diff --git a/frontend/src/components/features/chat/microagent/microagent-status-indicator.tsx b/frontend/src/components/features/chat/microagent/microagent-status-indicator.tsx index 319e1be01a..8d81f468ed 100644 --- a/frontend/src/components/features/chat/microagent/microagent-status-indicator.tsx +++ b/frontend/src/components/features/chat/microagent/microagent-status-indicator.tsx @@ -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 ; case MicroagentStatus.CREATING: return ; case MicroagentStatus.COMPLETED: diff --git a/frontend/src/components/features/chat/microagent/microagent-status-toast.tsx b/frontend/src/components/features/chat/microagent/microagent-status-toast.tsx index e0e6d5aaae..9df0487766 100644 --- a/frontend/src/components/features/chat/microagent/microagent-status-toast.tsx +++ b/frontend/src/components/features/chat/microagent/microagent-status-toast.tsx @@ -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 ( +
+ +
+ {t("MICROAGENT$CONVERSATION_STARTING")} +
+ + {t("MICROAGENT$VIEW_CONVERSATION")} + +
+ +
+ ); +} + 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 (
-
{errorMessage}
+
{displayMessage}
@@ -136,3 +176,18 @@ export const renderConversationErroredToast = ( duration: 5000, }, ); + +export const renderConversationStartingToast = (conversationId: string) => + toast( + (toastInstance) => ( + toast.dismiss(toastInstance.id)} + /> + ), + { + ...TOAST_OPTIONS, + id: `starting-${conversationId}`, + duration: 10000, // Show for 10 seconds or until dismissed + }, + ); diff --git a/frontend/src/components/features/microagent-management/microagent-management-accordion-title.tsx b/frontend/src/components/features/microagent-management/microagent-management-accordion-title.tsx index 605e614a30..9858cfa8f9 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-accordion-title.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-accordion-title.tsx @@ -17,7 +17,7 @@ export function MicroagentManagementAccordionTitle({ diff --git a/frontend/src/components/features/microagent-management/microagent-management-content.tsx b/frontend/src/components/features/microagent-management/microagent-management-content.tsx index 3771546f70..530d906f3b 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-content.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-content.tsx @@ -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 (
- + {providersAreSet && ( + + )}
@@ -345,7 +339,7 @@ export function MicroagentManagementContent() { return (
- + {providersAreSet && }
diff --git a/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx b/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx index 2569721b94..350d0f0c1a 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-microagent-card.tsx @@ -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]); diff --git a/frontend/src/components/features/microagent-management/microagent-management-repositories.tsx b/frontend/src/components/features/microagent-management/microagent-management-repositories.tsx index 3412687f07..653db7a46e 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-repositories.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-repositories.tsx @@ -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 (
- {/* Search Input */} -
- - 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", - )} - /> -
- {/* Repositories Accordion */} - {filteredRepositories.map((repository) => ( + {repositories.map((repository) => ( ( + 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 (
+ + {/* Provider Selection */} + {providers.length > 1 && ( +
+ +
+ )} + + {/* Search Input */} +
+ + 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", + )} + /> +
+ {isLoading ? (
diff --git a/frontend/src/context/conversation-subscriptions-provider.tsx b/frontend/src/context/conversation-subscriptions-provider.tsx index 36ebe3e190..a4fcecc659 100644 --- a/frontend/src/context/conversation-subscriptions-provider.tsx +++ b/frontend/src/context/conversation-subscriptions-provider.tsx @@ -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") { diff --git a/frontend/src/hooks/use-create-conversation-and-subscribe-multiple.ts b/frontend/src/hooks/use-create-conversation-and-subscribe-multiple.ts index 7216e6ef06..56bd665dc3 100644 --- a/frontend/src/hooks/use-create-conversation-and-subscribe-multiple.ts +++ b/frontend/src/hooks/use-create-conversation-and-subscribe-multiple.ts @@ -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 + >({}); + + // 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) => { + 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 { diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 079db4a59e..0349b9d1cf 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index a7229ba4e6..5ddcdc2cf6 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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アナライザー(デフォルト)", diff --git a/frontend/src/types/microagent-status.ts b/frontend/src/types/microagent-status.ts index 37deb9dd05..532cbffa02 100644 --- a/frontend/src/types/microagent-status.ts +++ b/frontend/src/types/microagent-status.ts @@ -1,4 +1,5 @@ export enum MicroagentStatus { + WAITING = "waiting", CREATING = "creating", COMPLETED = "completed", ERROR = "error", diff --git a/openhands/server/conversation_manager/docker_nested_conversation_manager.py b/openhands/server/conversation_manager/docker_nested_conversation_manager.py index e53953f7dd..eb46d3b313 100644 --- a/openhands/server/conversation_manager/docker_nested_conversation_manager.py +++ b/openhands/server/conversation_manager/docker_nested_conversation_manager.py @@ -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