From 794eedf503588054e303677973bf502aabc49630 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 8 Jul 2025 20:24:07 +0400 Subject: [PATCH] feat(frontend): Memory UI (#8592) Co-authored-by: openhands Co-authored-by: Engel Nyst Co-authored-by: tofarr Co-authored-by: Engel Nyst Co-authored-by: Robert Brennan Co-authored-by: Rohit Malhotra --- .../components/chat-message.test.tsx | 6 +- .../chat/launch-microagent-modal.test.tsx | 167 +++++++++ .../features/chat/messages.test.tsx | 107 ++++++ .../features/home/home-header.test.tsx | 13 +- .../features/home/repo-connector.test.tsx | 3 +- .../home/repo-selection-form.test.tsx | 5 + .../features/home/task-card.test.tsx | 9 +- .../shared/inputs/badge-input.test.tsx | 62 ++++ .../microagent-status-indicator.test.tsx | 105 ++++++ frontend/__tests__/parse-pr-url.test.ts | 142 ++++++++ .../check-home-hardcoded-strings.test.tsx | 42 --- .../scripts/check-unlocalized-strings.cjs | 1 + .../api/memory-service/memory-service.api.ts | 21 ++ frontend/src/api/open-hands.ts | 6 +- frontend/src/api/open-hands.types.ts | 2 +- .../components/features/chat/chat-message.tsx | 68 ++-- .../features/chat/event-message.tsx | 81 ++++- .../src/components/features/chat/messages.tsx | 201 ++++++++++- .../microagent/launch-microagent-modal.tsx | 163 +++++++++ .../microagent/loading-microagent-body.tsx | 16 + .../loading-microagent-textarea.tsx | 20 ++ .../microagent-status-indicator.tsx | 89 +++++ .../microagent/microagent-status-toast.tsx | 138 +++++++ .../conversation-panel/conversation-card.tsx | 5 +- .../conversation-panel/microagents-modal.tsx | 12 +- .../components/features/home/home-header.tsx | 12 +- .../home/repo-selection-form.test.tsx | 151 -------- .../features/home/repo-selection-form.tsx | 19 +- .../features/home/tasks/task-card.tsx | 21 +- .../settings/settings-dropdown-input.tsx | 15 +- frontend/src/components/shared/badge.tsx | 21 ++ .../buttons/copy-to-clipboard-button.tsx | 2 +- .../components/shared/inputs/badge-input.tsx | 75 ++++ .../conversation-subscriptions-provider.tsx | 315 ++++++++++++++++ frontend/src/context/ws-client-provider.tsx | 1 + frontend/src/entry.client.tsx | 1 + .../hooks/mutation/use-create-conversation.ts | 59 ++- .../query/use-conversation-microagents.ts | 24 +- .../hooks/query/use-get-microagent-prompt.ts | 12 - .../src/hooks/query/use-get-microagents.ts | 15 + .../src/hooks/query/use-microagent-prompt.ts | 15 + ...ate-conversation-and-subscribe-multiple.ts | 84 +++++ frontend/src/i18n/declaration.ts | 21 ++ frontend/src/i18n/translation.json | 337 ++++++++++++++++++ frontend/src/icons/memory_icon.svg | 24 ++ frontend/src/routes/conversation.tsx | 33 +- frontend/src/types/core/guards.ts | 23 +- frontend/src/types/core/variances.ts | 2 +- frontend/src/types/microagent-status.ts | 12 + frontend/src/utils/custom-toast-handlers.tsx | 2 +- frontend/src/utils/parse-pr-url.ts | 57 +++ 51 files changed, 2472 insertions(+), 365 deletions(-) create mode 100644 frontend/__tests__/components/features/chat/launch-microagent-modal.test.tsx create mode 100644 frontend/__tests__/components/features/chat/messages.test.tsx create mode 100644 frontend/__tests__/components/shared/inputs/badge-input.test.tsx create mode 100644 frontend/__tests__/microagent-status-indicator.test.tsx create mode 100644 frontend/__tests__/parse-pr-url.test.ts delete mode 100644 frontend/__tests__/utils/check-home-hardcoded-strings.test.tsx create mode 100644 frontend/src/api/memory-service/memory-service.api.ts create mode 100644 frontend/src/components/features/chat/microagent/launch-microagent-modal.tsx create mode 100644 frontend/src/components/features/chat/microagent/loading-microagent-body.tsx create mode 100644 frontend/src/components/features/chat/microagent/loading-microagent-textarea.tsx create mode 100644 frontend/src/components/features/chat/microagent/microagent-status-indicator.tsx create mode 100644 frontend/src/components/features/chat/microagent/microagent-status-toast.tsx delete mode 100644 frontend/src/components/features/home/repo-selection-form.test.tsx create mode 100644 frontend/src/components/shared/badge.tsx create mode 100644 frontend/src/components/shared/inputs/badge-input.tsx create mode 100644 frontend/src/context/conversation-subscriptions-provider.tsx delete mode 100644 frontend/src/hooks/query/use-get-microagent-prompt.ts create mode 100644 frontend/src/hooks/query/use-get-microagents.ts create mode 100644 frontend/src/hooks/query/use-microagent-prompt.ts create mode 100644 frontend/src/hooks/use-create-conversation-and-subscribe-multiple.ts create mode 100644 frontend/src/icons/memory_icon.svg create mode 100644 frontend/src/types/microagent-status.ts create mode 100644 frontend/src/utils/parse-pr-url.ts diff --git a/frontend/__tests__/components/chat-message.test.tsx b/frontend/__tests__/components/chat-message.test.tsx index c11b2df1b6..e719687984 100644 --- a/frontend/__tests__/components/chat-message.test.tsx +++ b/frontend/__tests__/components/chat-message.test.tsx @@ -10,9 +10,7 @@ describe("ChatMessage", () => { expect(screen.getByText("Hello, World!")).toBeInTheDocument(); }); - it.todo("should render an assistant message"); - - it.skip("should support code syntax highlighting", () => { + it("should support code syntax highlighting", () => { const code = "```js\nconsole.log('Hello, World!')\n```"; render(); @@ -46,8 +44,6 @@ describe("ChatMessage", () => { ); }); - it("should display an error toast if copying content to clipboard fails", async () => {}); - it("should render a component passed as a prop", () => { function Component() { return
Custom Component
; diff --git a/frontend/__tests__/components/features/chat/launch-microagent-modal.test.tsx b/frontend/__tests__/components/features/chat/launch-microagent-modal.test.tsx new file mode 100644 index 0000000000..41238fdb6e --- /dev/null +++ b/frontend/__tests__/components/features/chat/launch-microagent-modal.test.tsx @@ -0,0 +1,167 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { LaunchMicroagentModal } from "#/components/features/chat/microagent/launch-microagent-modal"; +import { MemoryService } from "#/api/memory-service/memory-service.api"; +import { FileService } from "#/api/file-service/file-service.api"; +import { I18nKey } from "#/i18n/declaration"; + +vi.mock("react-router", async () => ({ + useParams: vi.fn().mockReturnValue({ + conversationId: "123", + }), +})); + +// Mock the useHandleRuntimeActive hook +vi.mock("#/hooks/use-handle-runtime-active", () => ({ + useHandleRuntimeActive: vi.fn().mockReturnValue({ runtimeActive: true }), +})); + +// Mock the useMicroagentPrompt hook +vi.mock("#/hooks/query/use-microagent-prompt", () => ({ + useMicroagentPrompt: vi.fn().mockReturnValue({ + data: "Generated prompt", + isLoading: false + }), +})); + +// Mock the useGetMicroagents hook +vi.mock("#/hooks/query/use-get-microagents", () => ({ + useGetMicroagents: vi.fn().mockReturnValue({ + data: ["file1", "file2"] + }), +})); + +// Mock the useTranslation hook +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + [I18nKey.MICROAGENT$ADD_TO_MICROAGENT]: "Add to Microagent", + [I18nKey.MICROAGENT$WHAT_TO_REMEMBER]: "What would you like your microagent to remember?", + [I18nKey.MICROAGENT$WHERE_TO_PUT]: "Where should we put it?", + [I18nKey.MICROAGENT$ADD_TRIGGERS]: "Add triggers for the microagent", + [I18nKey.MICROAGENT$DESCRIBE_WHAT_TO_ADD]: "Describe what you want to add to the Microagent...", + [I18nKey.MICROAGENT$SELECT_FILE_OR_CUSTOM]: "Select a microagent file or enter a custom value", + [I18nKey.MICROAGENT$TYPE_TRIGGER_SPACE]: "Type a trigger and press Space to add it", + [I18nKey.MICROAGENT$LOADING_PROMPT]: "Loading prompt...", + [I18nKey.MICROAGENT$CANCEL]: "Cancel", + [I18nKey.MICROAGENT$LAUNCH]: "Launch" + }; + return translations[key] || key; + }, + i18n: { + changeLanguage: vi.fn(), + }, + }), + Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, +})); + +describe("LaunchMicroagentModal", () => { + const onCloseMock = vi.fn(); + const onLaunchMock = vi.fn(); + const eventId = 12; + const conversationId = "123"; + + const renderMicroagentModal = ( + { isLoading }: { isLoading: boolean } = { isLoading: false }, + ) => + render( + , + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render the launch microagent modal", () => { + renderMicroagentModal(); + expect(screen.getByTestId("launch-microagent-modal")).toBeInTheDocument(); + }); + + it("should render the form fields", () => { + renderMicroagentModal(); + + // inputs + screen.getByTestId("query-input"); + screen.getByTestId("target-input"); + screen.getByTestId("trigger-input"); + + // action buttons + screen.getByRole("button", { name: "Launch" }); + screen.getByRole("button", { name: "Cancel" }); + }); + + it("should call onClose when pressing the cancel button", async () => { + renderMicroagentModal(); + + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await userEvent.click(cancelButton); + expect(onCloseMock).toHaveBeenCalled(); + }); + + it("should display the prompt from the hook", async () => { + renderMicroagentModal(); + + // Since we're mocking the hook, we just need to verify the UI shows the data + const descriptionInput = screen.getByTestId("query-input"); + expect(descriptionInput).toHaveValue("Generated prompt"); + }); + + it("should display the list of microagent files from the hook", async () => { + renderMicroagentModal(); + + // Since we're mocking the hook, we just need to verify the UI shows the data + const targetInput = screen.getByTestId("target-input"); + expect(targetInput).toHaveValue(""); + + await userEvent.click(targetInput); + + expect(screen.getByText("file1")).toBeInTheDocument(); + expect(screen.getByText("file2")).toBeInTheDocument(); + + await userEvent.click(screen.getByText("file1")); + expect(targetInput).toHaveValue("file1"); + }); + + it("should call onLaunch with the form data", async () => { + renderMicroagentModal(); + + const triggerInput = screen.getByTestId("trigger-input"); + await userEvent.type(triggerInput, "trigger1 "); + await userEvent.type(triggerInput, "trigger2 "); + + const targetInput = screen.getByTestId("target-input"); + await userEvent.click(targetInput); + await userEvent.click(screen.getByText("file1")); + + const launchButton = await screen.findByRole("button", { name: "Launch" }); + await userEvent.click(launchButton); + + expect(onLaunchMock).toHaveBeenCalledWith("Generated prompt", "file1", [ + "trigger1", + "trigger2", + ]); + }); + + it("should disable the launch button if isLoading is true", async () => { + renderMicroagentModal({ isLoading: true }); + + const launchButton = screen.getByRole("button", { name: "Launch" }); + expect(launchButton).toBeDisabled(); + }); +}); diff --git a/frontend/__tests__/components/features/chat/messages.test.tsx b/frontend/__tests__/components/features/chat/messages.test.tsx new file mode 100644 index 0000000000..0d3c7dce20 --- /dev/null +++ b/frontend/__tests__/components/features/chat/messages.test.tsx @@ -0,0 +1,107 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Messages } from "#/components/features/chat/messages"; +import { + AssistantMessageAction, + OpenHandsAction, + UserMessageAction, +} from "#/types/core/actions"; +import { OpenHandsObservation } from "#/types/core/observations"; +import OpenHands from "#/api/open-hands"; +import { Conversation } from "#/api/open-hands.types"; + +vi.mock("react-router", () => ({ + useParams: () => ({ conversationId: "123" }), +})); + +let queryClient: QueryClient; + +const renderMessages = ({ + messages, +}: { + messages: (OpenHandsAction | OpenHandsObservation)[]; +}) => { + const { rerender, ...rest } = render( + , + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + const rerenderMessages = ( + newMessages: (OpenHandsAction | OpenHandsObservation)[], + ) => { + rerender( + , + ); + }; + + return { ...rest, rerender: rerenderMessages }; +}; + +describe("Messages", () => { + beforeEach(() => { + queryClient = new QueryClient(); + }); + + const assistantMessage: AssistantMessageAction = { + id: 0, + action: "message", + source: "agent", + message: "Hello, Assistant!", + timestamp: new Date().toISOString(), + args: { + image_urls: [], + file_urls: [], + thought: "", + wait_for_response: false, + }, + }; + + const userMessage: UserMessageAction = { + id: 1, + action: "message", + source: "user", + message: "Hello, User!", + timestamp: new Date().toISOString(), + args: { content: "Hello, User!", image_urls: [], file_urls: [] }, + }; + + it("should render", () => { + renderMessages({ messages: [userMessage, assistantMessage] }); + + expect(screen.getByText("Hello, User!")).toBeInTheDocument(); + expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument(); + }); + + it("should render a launch to microagent action button on chat messages only if it is a user message", () => { + const getConversationSpy = vi.spyOn(OpenHands, "getConversation"); + const mockConversation: Conversation = { + conversation_id: "123", + title: "Test Conversation", + status: "RUNNING", + runtime_status: "STATUS$READY", + created_at: new Date().toISOString(), + last_updated_at: new Date().toISOString(), + selected_branch: null, + selected_repository: null, + git_provider: "github", + session_api_key: null, + url: null, + }; + + getConversationSpy.mockResolvedValue(mockConversation); + + renderMessages({ + messages: [userMessage, assistantMessage], + }); + + expect(screen.getByText("Hello, User!")).toBeInTheDocument(); + expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/features/home/home-header.test.tsx b/frontend/__tests__/components/features/home/home-header.test.tsx index a762b2f150..17e92a2ec1 100644 --- a/frontend/__tests__/components/features/home/home-header.test.tsx +++ b/frontend/__tests__/components/features/home/home-header.test.tsx @@ -17,12 +17,12 @@ vi.mock("react-i18next", async () => { t: (key: string) => { // Return a mock translation for the test const translations: Record = { - "HOME$LETS_START_BUILDING": "Let's start building", - "HOME$LAUNCH_FROM_SCRATCH": "Launch from Scratch", - "HOME$LOADING": "Loading...", - "HOME$OPENHANDS_DESCRIPTION": "OpenHands is an AI software engineer", - "HOME$NOT_SURE_HOW_TO_START": "Not sure how to start?", - "HOME$READ_THIS": "Read this" + HOME$LETS_START_BUILDING: "Let's start building", + HOME$LAUNCH_FROM_SCRATCH: "Launch from Scratch", + HOME$LOADING: "Loading...", + HOME$OPENHANDS_DESCRIPTION: "OpenHands is an AI software engineer", + HOME$NOT_SURE_HOW_TO_START: "Not sure how to start?", + HOME$READ_THIS: "Read this", }; return translations[key] || key; }, @@ -69,7 +69,6 @@ describe("HomeHeader", () => { undefined, undefined, undefined, - [], undefined, undefined, undefined, diff --git a/frontend/__tests__/components/features/home/repo-connector.test.tsx b/frontend/__tests__/components/features/home/repo-connector.test.tsx index 908b692cf5..63b3a85dcf 100644 --- a/frontend/__tests__/components/features/home/repo-connector.test.tsx +++ b/frontend/__tests__/components/features/home/repo-connector.test.tsx @@ -206,9 +206,8 @@ describe("RepoConnector", () => { "rbren/polaris", "github", undefined, - [], - undefined, undefined, + "main", undefined, ); }); diff --git a/frontend/__tests__/components/features/home/repo-selection-form.test.tsx b/frontend/__tests__/components/features/home/repo-selection-form.test.tsx index 563f0d3c16..d8a0a91877 100644 --- a/frontend/__tests__/components/features/home/repo-selection-form.test.tsx +++ b/frontend/__tests__/components/features/home/repo-selection-form.test.tsx @@ -66,6 +66,11 @@ vi.mock("#/hooks/use-debounce", () => ({ useDebounce: (value: string) => value, })); +vi.mock("react-router", async (importActual) => ({ + ...(await importActual()), + useNavigate: vi.fn(), +})); + const mockOnRepoSelection = vi.fn(); const renderForm = () => render(, { diff --git a/frontend/__tests__/components/features/home/task-card.test.tsx b/frontend/__tests__/components/features/home/task-card.test.tsx index 4d8b857828..4b0b390bc9 100644 --- a/frontend/__tests__/components/features/home/task-card.test.tsx +++ b/frontend/__tests__/components/features/home/task-card.test.tsx @@ -88,9 +88,14 @@ describe("TaskCard", () => { MOCK_RESPOSITORIES[0].full_name, MOCK_RESPOSITORIES[0].git_provider, undefined, - [], + { + git_provider: "github", + issue_number: 123, + repo: "repo1", + task_type: "MERGE_CONFLICTS", + title: "Task 1", + }, undefined, - MOCK_TASK_1, undefined, ); }); diff --git a/frontend/__tests__/components/shared/inputs/badge-input.test.tsx b/frontend/__tests__/components/shared/inputs/badge-input.test.tsx new file mode 100644 index 0000000000..18fff61ee6 --- /dev/null +++ b/frontend/__tests__/components/shared/inputs/badge-input.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { BadgeInput } from "#/components/shared/inputs/badge-input"; + +describe("BadgeInput", () => { + it("should render the values", () => { + const onChangeMock = vi.fn(); + render(); + + expect(screen.getByText("test")).toBeInTheDocument(); + expect(screen.getByText("test2")).toBeInTheDocument(); + }); + + it("should render the input's as a badge on space", async () => { + const onChangeMock = vi.fn(); + render(); + + const input = screen.getByTestId("badge-input"); + expect(input).toHaveValue(""); + + await userEvent.type(input, "test"); + await userEvent.type(input, " "); + + expect(onChangeMock).toHaveBeenCalledWith(["badge1", "test"]); + expect(input).toHaveValue(""); + }); + + it("should remove the badge on backspace", async () => { + const onChangeMock = vi.fn(); + render(); + + const input = screen.getByTestId("badge-input"); + expect(input).toHaveValue(""); + + await userEvent.type(input, "{backspace}"); + + expect(onChangeMock).toHaveBeenCalledWith(["badge1"]); + expect(input).toHaveValue(""); + }); + + it("should remove the badge on click", async () => { + const onChangeMock = vi.fn(); + render(); + + const removeButton = screen.getByTestId("remove-button"); + await userEvent.click(removeButton); + + expect(onChangeMock).toHaveBeenCalledWith([]); + }); + + it("should not create empty badges", async () => { + const onChangeMock = vi.fn(); + render(); + + const input = screen.getByTestId("badge-input"); + expect(input).toHaveValue(""); + + await userEvent.type(input, " "); + expect(onChangeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/__tests__/microagent-status-indicator.test.tsx b/frontend/__tests__/microagent-status-indicator.test.tsx new file mode 100644 index 0000000000..3e4f9cb3b4 --- /dev/null +++ b/frontend/__tests__/microagent-status-indicator.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MicroagentStatusIndicator } from "#/components/features/chat/microagent/microagent-status-indicator"; +import { MicroagentStatus } from "#/types/microagent-status"; + +// Mock the translation hook +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe("MicroagentStatusIndicator", () => { + it("should show 'View your PR' when status is completed and PR URL is provided", () => { + render( + , + ); + + const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute( + "href", + "https://github.com/owner/repo/pull/123", + ); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should show default completed message when status is completed but no PR URL", () => { + render( + , + ); + + const link = screen.getByRole("link", { + name: "MICROAGENT$STATUS_COMPLETED", + }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "/conversations/test-conversation"); + }); + + it("should show creating status without PR URL", () => { + render( + , + ); + + expect(screen.getByText("MICROAGENT$STATUS_CREATING")).toBeInTheDocument(); + }); + + it("should show error status", () => { + render( + , + ); + + expect(screen.getByText("MICROAGENT$STATUS_ERROR")).toBeInTheDocument(); + }); + + it("should prioritize PR URL over conversation link when both are provided", () => { + render( + , + ); + + const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" }); + expect(link).toHaveAttribute( + "href", + "https://github.com/owner/repo/pull/123", + ); + // Should not link to conversation when PR URL is available + expect(link).not.toHaveAttribute( + "href", + "/conversations/test-conversation", + ); + }); + + it("should work with GitLab MR URLs", () => { + render( + , + ); + + const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" }); + expect(link).toHaveAttribute( + "href", + "https://gitlab.com/owner/repo/-/merge_requests/456", + ); + }); +}); diff --git a/frontend/__tests__/parse-pr-url.test.ts b/frontend/__tests__/parse-pr-url.test.ts new file mode 100644 index 0000000000..fc4ed69ee8 --- /dev/null +++ b/frontend/__tests__/parse-pr-url.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { + extractPRUrls, + containsPRUrl, + getFirstPRUrl, +} from "#/utils/parse-pr-url"; + +describe("parse-pr-url", () => { + describe("extractPRUrls", () => { + it("should extract GitHub PR URLs", () => { + const text = "Check out this PR: https://github.com/owner/repo/pull/123"; + const urls = extractPRUrls(text); + expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]); + }); + + it("should extract GitLab MR URLs", () => { + const text = + "Merge request: https://gitlab.com/owner/repo/-/merge_requests/456"; + const urls = extractPRUrls(text); + expect(urls).toEqual([ + "https://gitlab.com/owner/repo/-/merge_requests/456", + ]); + }); + + it("should extract Bitbucket PR URLs", () => { + const text = + "PR link: https://bitbucket.org/owner/repo/pull-requests/789"; + const urls = extractPRUrls(text); + expect(urls).toEqual([ + "https://bitbucket.org/owner/repo/pull-requests/789", + ]); + }); + + it("should extract Azure DevOps PR URLs", () => { + const text = + "Azure PR: https://dev.azure.com/org/project/_git/repo/pullrequest/101"; + const urls = extractPRUrls(text); + expect(urls).toEqual([ + "https://dev.azure.com/org/project/_git/repo/pullrequest/101", + ]); + }); + + it("should extract multiple PR URLs", () => { + const text = ` + GitHub: https://github.com/owner/repo/pull/123 + GitLab: https://gitlab.com/owner/repo/-/merge_requests/456 + `; + const urls = extractPRUrls(text); + expect(urls).toHaveLength(2); + expect(urls).toContain("https://github.com/owner/repo/pull/123"); + expect(urls).toContain( + "https://gitlab.com/owner/repo/-/merge_requests/456", + ); + }); + + it("should handle self-hosted GitLab URLs", () => { + const text = + "Self-hosted: https://gitlab.example.com/owner/repo/-/merge_requests/123"; + const urls = extractPRUrls(text); + expect(urls).toEqual([ + "https://gitlab.example.com/owner/repo/-/merge_requests/123", + ]); + }); + + it("should return empty array when no PR URLs found", () => { + const text = "This is just regular text with no PR URLs"; + const urls = extractPRUrls(text); + expect(urls).toEqual([]); + }); + + it("should handle URLs with HTTP instead of HTTPS", () => { + const text = "HTTP PR: http://github.com/owner/repo/pull/123"; + const urls = extractPRUrls(text); + expect(urls).toEqual(["http://github.com/owner/repo/pull/123"]); + }); + + it("should remove duplicate URLs", () => { + const text = ` + Same PR mentioned twice: + https://github.com/owner/repo/pull/123 + https://github.com/owner/repo/pull/123 + `; + const urls = extractPRUrls(text); + expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]); + }); + }); + + describe("containsPRUrl", () => { + it("should return true when PR URL is present", () => { + const text = "Check out this PR: https://github.com/owner/repo/pull/123"; + expect(containsPRUrl(text)).toBe(true); + }); + + it("should return false when no PR URL is present", () => { + const text = "This is just regular text"; + expect(containsPRUrl(text)).toBe(false); + }); + }); + + describe("getFirstPRUrl", () => { + it("should return the first PR URL found", () => { + const text = ` + First: https://github.com/owner/repo/pull/123 + Second: https://gitlab.com/owner/repo/-/merge_requests/456 + `; + const url = getFirstPRUrl(text); + expect(url).toBe("https://github.com/owner/repo/pull/123"); + }); + + it("should return null when no PR URL is found", () => { + const text = "This is just regular text"; + const url = getFirstPRUrl(text); + expect(url).toBeNull(); + }); + }); + + describe("real-world scenarios", () => { + it("should handle typical microagent finish messages", () => { + const text = ` + I have successfully created a pull request with the requested changes. + You can view the PR here: https://github.com/All-Hands-AI/OpenHands/pull/1234 + + The changes include: + - Updated the component + - Added tests + - Fixed the issue + `; + const url = getFirstPRUrl(text); + expect(url).toBe("https://github.com/All-Hands-AI/OpenHands/pull/1234"); + }); + + it("should handle messages with PR URLs in the middle", () => { + const text = ` + Task completed successfully! I've created a pull request at + https://github.com/owner/repo/pull/567 with all the requested changes. + Please review when you have a chance. + `; + const url = getFirstPRUrl(text); + expect(url).toBe("https://github.com/owner/repo/pull/567"); + }); + }); +}); diff --git a/frontend/__tests__/utils/check-home-hardcoded-strings.test.tsx b/frontend/__tests__/utils/check-home-hardcoded-strings.test.tsx deleted file mode 100644 index f0920417c9..0000000000 --- a/frontend/__tests__/utils/check-home-hardcoded-strings.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { render } from "@testing-library/react"; -import { test, expect, describe, vi } from "vitest"; -import { HomeHeader } from "#/components/features/home/home-header"; - -// Mock dependencies -vi.mock("#/hooks/mutation/use-create-conversation", () => ({ - useCreateConversation: () => ({ - mutate: vi.fn(), - isPending: false, - isSuccess: false, - }), -})); - -vi.mock("#/hooks/use-is-creating-conversation", () => ({ - useIsCreatingConversation: () => false, -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -describe("Check for hardcoded English strings in Home components", () => { - test("HomeHeader should not have hardcoded English strings", () => { - const { container } = render(); - - // Get all text content - const text = container.textContent; - - // List of English strings that should be translated - const hardcodedStrings = [ - "Launch from Scratch", - "Read this", - ]; - - // Check each string - hardcodedStrings.forEach((str) => { - expect(text).not.toContain(str); - }); - }); -}); diff --git a/frontend/scripts/check-unlocalized-strings.cjs b/frontend/scripts/check-unlocalized-strings.cjs index 1f0a0d0d08..7dea330a5b 100755 --- a/frontend/scripts/check-unlocalized-strings.cjs +++ b/frontend/scripts/check-unlocalized-strings.cjs @@ -114,6 +114,7 @@ const EXCLUDED_TECHNICAL_STRINGS = [ "edit-secret-form", // Test ID for secret form "search-api-key-input", // Input name for search API key "noopener,noreferrer", // Options for window.open + ".openhands/microagents/", // Path to microagents directory "STATUS$READY", "STATUS$STOPPED", "STATUS$ERROR", diff --git a/frontend/src/api/memory-service/memory-service.api.ts b/frontend/src/api/memory-service/memory-service.api.ts new file mode 100644 index 0000000000..0818894c58 --- /dev/null +++ b/frontend/src/api/memory-service/memory-service.api.ts @@ -0,0 +1,21 @@ +import { openHands } from "../open-hands-axios"; + +interface GetPromptResponse { + status: string; + prompt: string; +} + +export class MemoryService { + static async getPrompt( + conversationId: string, + eventId: number, + ): Promise { + const { data } = await openHands.get( + `/api/conversations/${conversationId}/remember_prompt`, + { + params: { event_id: eventId }, + }, + ); + return data.prompt; + } +} diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index 3520ba375e..e836a01b50 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -258,19 +258,17 @@ class OpenHands { selectedRepository?: string, git_provider?: Provider, initialUserMsg?: string, - imageUrls?: string[], - replayJson?: string, suggested_task?: SuggestedTask, selected_branch?: string, + conversationInstructions?: string, ): Promise { const body = { repository: selectedRepository, git_provider, selected_branch, initial_user_msg: initialUserMsg, - image_urls: imageUrls, - replay_json: replayJson, suggested_task, + conversation_instructions: conversationInstructions, }; const { data } = await openHands.post( diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index 581a2d9a9c..4acdd16797 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/frontend/src/api/open-hands.types.ts @@ -84,7 +84,7 @@ export interface Conversation { title: string; selected_repository: string | null; selected_branch: string | null; - git_provider: string | null; + git_provider: Provider | null; last_updated_at: string; created_at: string; status: ConversationStatus; diff --git a/frontend/src/components/features/chat/chat-message.tsx b/frontend/src/components/features/chat/chat-message.tsx index 6b2cf58516..acf17315f7 100644 --- a/frontend/src/components/features/chat/chat-message.tsx +++ b/frontend/src/components/features/chat/chat-message.tsx @@ -12,12 +12,17 @@ import { paragraph } from "../markdown/paragraph"; interface ChatMessageProps { type: OpenHandsSourceType; message: string; + actions?: Array<{ + icon: React.ReactNode; + onClick: () => void; + }>; } export function ChatMessage({ type, message, children, + actions, }: React.PropsWithChildren) { const [isHovering, setIsHovering] = React.useState(false); const [isCopy, setIsCopy] = React.useState(false); @@ -47,31 +52,54 @@ export function ChatMessage({ onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} className={cn( - "rounded-xl relative", + "rounded-xl relative w-fit", "flex flex-col gap-2", type === "user" && " max-w-[305px] p-4 bg-tertiary self-end", type === "agent" && "mt-6 max-w-full bg-transparent", )} > - -
- - {message} - +
+ {actions?.map((action, index) => ( + + ))} + + +
+ +
+
+ + {message} + +
{children} diff --git a/frontend/src/components/features/chat/event-message.tsx b/frontend/src/components/features/chat/event-message.tsx index 537042e69a..98d63fcc72 100644 --- a/frontend/src/components/features/chat/event-message.tsx +++ b/frontend/src/components/features/chat/event-message.tsx @@ -19,6 +19,8 @@ import { MCPObservationContent } from "./mcp-observation-content"; import { getObservationResult } from "./event-content-helpers/get-observation-result"; import { getEventContent } from "./event-content-helpers/get-event-content"; import { GenericEventMessage } from "./generic-event-message"; +import { MicroagentStatus } from "#/types/microagent-status"; +import { MicroagentStatusIndicator } from "./microagent/microagent-status-indicator"; import { FileList } from "../files/file-list"; import { parseMessageFromEvent } from "./event-content-helpers/parse-message-from-event"; import { LikertScale } from "../feedback/likert-scale"; @@ -35,6 +37,13 @@ interface EventMessageProps { hasObservationPair: boolean; isAwaitingUserConfirmation: boolean; isLastMessage: boolean; + microagentStatus?: MicroagentStatus | null; + microagentConversationId?: string; + microagentPRUrl?: string; + actions?: Array<{ + icon: React.ReactNode; + onClick: () => void; + }>; isInLast10Actions: boolean; } @@ -43,6 +52,10 @@ export function EventMessage({ hasObservationPair, isAwaitingUserConfirmation, isLastMessage, + microagentStatus, + microagentConversationId, + microagentPRUrl, + actions, isInLast10Actions, }: EventMessageProps) { const shouldShowConfirmationButtons = @@ -82,27 +95,66 @@ export function EventMessage({ if (isErrorObservation(event)) { return ( - <> +
+ {microagentStatus && actions && ( + + )} {renderLikertScale()} - +
); } if (hasObservationPair && isOpenHandsAction(event)) { if (hasThoughtProperty(event.args)) { - return ; + return ( +
+ + {microagentStatus && actions && ( + + )} +
+ ); } - return null; + return microagentStatus && actions ? ( + + ) : null; } if (isFinishAction(event)) { return ( <> - + + {microagentStatus && actions && ( + + )} {renderLikertScale()} ); @@ -112,8 +164,8 @@ export function EventMessage({ const message = parseMessageFromEvent(event); return ( - <> - +
+ {event.args.image_urls && event.args.image_urls.length > 0 && ( )} @@ -122,15 +174,26 @@ export function EventMessage({ )} {shouldShowConfirmationButtons && } + {microagentStatus && actions && ( + + )} {isAssistantMessage(event) && event.action === "message" && renderLikertScale()} - +
); } if (isRejectObservation(event)) { - return ; + return ( +
+ +
+ ); } if (isMcpObservation(event)) { diff --git a/frontend/src/components/features/chat/messages.tsx b/frontend/src/components/features/chat/messages.tsx index 04b3ad9824..ecfc7546d6 100644 --- a/frontend/src/components/features/chat/messages.tsx +++ b/frontend/src/components/features/chat/messages.tsx @@ -1,10 +1,28 @@ import React from "react"; +import { createPortal } from "react-dom"; import { OpenHandsAction } from "#/types/core/actions"; import { OpenHandsObservation } from "#/types/core/observations"; -import { isOpenHandsAction, isOpenHandsObservation } from "#/types/core/guards"; +import { + isOpenHandsAction, + isOpenHandsObservation, + isOpenHandsEvent, + isAgentStateChangeObservation, + isFinishAction, +} from "#/types/core/guards"; import { EventMessage } from "./event-message"; import { ChatMessage } from "./chat-message"; import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message"; +import { LaunchMicroagentModal } from "./microagent/launch-microagent-modal"; +import { useUserConversation } from "#/hooks/query/use-user-conversation"; +import { useConversationId } from "#/hooks/use-conversation-id"; +import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple"; +import { + MicroagentStatus, + EventMicroagentStatus, +} from "#/types/microagent-status"; +import { AgentState } from "#/types/agent-state"; +import { getFirstPRUrl } from "#/utils/parse-pr-url"; +import MemoryIcon from "#/icons/memory_icon.svg?react"; interface MessagesProps { messages: (OpenHandsAction | OpenHandsObservation)[]; @@ -13,10 +31,23 @@ interface MessagesProps { export const Messages: React.FC = React.memo( ({ messages, isAwaitingUserConfirmation }) => { + const { createConversationAndSubscribe, isPending } = + useCreateConversationAndSubscribeMultiple(); const { getOptimisticUserMessage } = useOptimisticUserMessage(); + const { conversationId } = useConversationId(); + const { data: conversation } = useUserConversation(conversationId); const optimisticUserMessage = getOptimisticUserMessage(); + const [selectedEventId, setSelectedEventId] = React.useState( + null, + ); + const [showLaunchMicroagentModal, setShowLaunchMicroagentModal] = + React.useState(false); + const [microagentStatuses, setMicroagentStatuses] = React.useState< + EventMicroagentStatus[] + >([]); + const actionHasObservationPair = React.useCallback( (event: OpenHandsAction | OpenHandsObservation): boolean => { if (isOpenHandsAction(event)) { @@ -30,6 +61,139 @@ export const Messages: React.FC = React.memo( [messages], ); + const getMicroagentStatusForEvent = React.useCallback( + (eventId: number): MicroagentStatus | null => { + const statusEntry = microagentStatuses.find( + (entry) => entry.eventId === eventId, + ); + return statusEntry?.status || null; + }, + [microagentStatuses], + ); + + const getMicroagentConversationIdForEvent = React.useCallback( + (eventId: number): string | undefined => { + const statusEntry = microagentStatuses.find( + (entry) => entry.eventId === eventId, + ); + return statusEntry?.conversationId || undefined; + }, + [microagentStatuses], + ); + + const getMicroagentPRUrlForEvent = React.useCallback( + (eventId: number): string | undefined => { + const statusEntry = microagentStatuses.find( + (entry) => entry.eventId === eventId, + ); + return statusEntry?.prUrl || undefined; + }, + [microagentStatuses], + ); + + 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) => + statusEntry.conversationId === microagentConversationId + ? { ...statusEntry, status: MicroagentStatus.ERROR } + : statusEntry, + ), + ); + } else if ( + isOpenHandsEvent(socketEvent) && + isAgentStateChangeObservation(socketEvent) + ) { + if (socketEvent.extras.agent_state === AgentState.FINISHED) { + setMicroagentStatuses((prev) => + prev.map((statusEntry) => + statusEntry.conversationId === microagentConversationId + ? { ...statusEntry, status: MicroagentStatus.COMPLETED } + : statusEntry, + ), + ); + } + } else if ( + isOpenHandsEvent(socketEvent) && + isFinishAction(socketEvent) + ) { + // Check if the finish action contains a PR URL + const prUrl = getFirstPRUrl(socketEvent.args.final_thought || ""); + if (prUrl) { + setMicroagentStatuses((prev) => + prev.map((statusEntry) => + statusEntry.conversationId === microagentConversationId + ? { + ...statusEntry, + status: MicroagentStatus.COMPLETED, + prUrl, + } + : statusEntry, + ), + ); + } + } + }, + [setMicroagentStatuses], + ); + + const handleLaunchMicroagent = ( + query: string, + target: string, + triggers: string[], + ) => { + const conversationInstructions = `Target file: ${target}\n\nDescription: ${query}\n\nTriggers: ${triggers.join(", ")}`; + if ( + !conversation || + !conversation.selected_repository || + !conversation.selected_branch || + !conversation.git_provider || + !selectedEventId + ) { + return; + } + + createConversationAndSubscribe({ + query, + conversationInstructions, + repository: { + name: conversation.selected_repository, + branch: conversation.selected_branch, + gitProvider: conversation.git_provider, + }, + onSuccessCallback: (newConversationId: string) => { + setShowLaunchMicroagentModal(false); + // Update status with conversation ID + setMicroagentStatuses((prev) => [ + ...prev.filter((status) => status.eventId !== selectedEventId), + { + eventId: selectedEventId, + conversationId: newConversationId, + status: MicroagentStatus.CREATING, + }, + ]); + }, + onEventCallback: (socketEvent: unknown, newConversationId: string) => { + handleMicroagentEvent(socketEvent, newConversationId); + }, + }); + }; + return ( <> {messages.map((message, index) => ( @@ -39,6 +203,26 @@ export const Messages: React.FC = React.memo( hasObservationPair={actionHasObservationPair(message)} isAwaitingUserConfirmation={isAwaitingUserConfirmation} isLastMessage={messages.length - 1 === index} + microagentStatus={getMicroagentStatusForEvent(message.id)} + microagentConversationId={getMicroagentConversationIdForEvent( + message.id, + )} + microagentPRUrl={getMicroagentPRUrlForEvent(message.id)} + actions={ + conversation?.selected_repository + ? [ + { + icon: ( + + ), + onClick: () => { + setSelectedEventId(message.id); + setShowLaunchMicroagentModal(true); + }, + }, + ] + : undefined + } isInLast10Actions={messages.length - 1 - index < 10} /> ))} @@ -46,6 +230,21 @@ export const Messages: React.FC = React.memo( {optimisticUserMessage && ( )} + {conversation?.selected_repository && + showLaunchMicroagentModal && + selectedEventId && + createPortal( + setShowLaunchMicroagentModal(false)} + onLaunch={handleLaunchMicroagent} + selectedRepo={ + conversation.selected_repository.split("/").pop() || "" + } + eventId={selectedEventId} + isLoading={isPending} + />, + document.getElementById("modal-portal-exit") || document.body, + )} ); }, diff --git a/frontend/src/components/features/chat/microagent/launch-microagent-modal.tsx b/frontend/src/components/features/chat/microagent/launch-microagent-modal.tsx new file mode 100644 index 0000000000..00888d99d2 --- /dev/null +++ b/frontend/src/components/features/chat/microagent/launch-microagent-modal.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import { FaCircleInfo } from "react-icons/fa6"; +import { useTranslation } from "react-i18next"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { BrandButton } from "../../settings/brand-button"; +import { SettingsDropdownInput } from "../../settings/settings-dropdown-input"; +import { BadgeInput } from "#/components/shared/inputs/badge-input"; +import { cn } from "#/utils/utils"; +import CloseIcon from "#/icons/close.svg?react"; +import { useMicroagentPrompt } from "#/hooks/query/use-microagent-prompt"; +import { useHandleRuntimeActive } from "#/hooks/use-handle-runtime-active"; +import { LoadingMicroagentBody } from "./loading-microagent-body"; +import { LoadingMicroagentTextarea } from "./loading-microagent-textarea"; +import { useGetMicroagents } from "#/hooks/query/use-get-microagents"; + +interface LaunchMicroagentModalProps { + onClose: () => void; + onLaunch: (query: string, target: string, triggers: string[]) => void; + eventId: number; + isLoading: boolean; + selectedRepo: string; +} + +export function LaunchMicroagentModal({ + onClose, + onLaunch, + eventId, + isLoading, + selectedRepo, +}: LaunchMicroagentModalProps) { + const { t } = useTranslation(); + const { runtimeActive } = useHandleRuntimeActive(); + const { data: prompt, isLoading: promptIsLoading } = + useMicroagentPrompt(eventId); + + const { data: microagents, isLoading: microagentsIsLoading } = + useGetMicroagents(`${selectedRepo}/.openhands/microagents`); + + const [triggers, setTriggers] = React.useState([]); + + const formAction = (formData: FormData) => { + const query = formData.get("query-input")?.toString(); + const target = formData.get("target-input")?.toString(); + + if (query && target) { + onLaunch(query, target, triggers); + } + }; + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + formAction(formData); + }; + + return ( + + {!runtimeActive && } + {runtimeActive && ( + +
+

+ {t("MICROAGENT$ADD_TO_MICROAGENT")} + + + +

+ + +
+ +
+