mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(frontend): Memory UI (#8592)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Engel Nyst <engel.nyst@gmail.com> Co-authored-by: tofarr <tofarr@gmail.com> Co-authored-by: Engel Nyst <enyst@users.noreply.github.com> Co-authored-by: Robert Brennan <accounts@rbren.io> Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
This commit is contained in:
parent
a6ffb2f799
commit
794eedf503
@ -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(<ChatMessage type="user" message={code} />);
|
||||
|
||||
@ -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 <div data-testid="custom-component">Custom Component</div>;
|
||||
|
||||
@ -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<string, string> = {
|
||||
[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(
|
||||
<LaunchMicroagentModal
|
||||
onClose={onCloseMock}
|
||||
onLaunch={onLaunchMock}
|
||||
eventId={eventId}
|
||||
selectedRepo="some-repo"
|
||||
isLoading={isLoading}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
107
frontend/__tests__/components/features/chat/messages.test.tsx
Normal file
107
frontend/__tests__/components/features/chat/messages.test.tsx
Normal file
@ -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(
|
||||
<Messages messages={messages} isAwaitingUserConfirmation={false} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient!}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const rerenderMessages = (
|
||||
newMessages: (OpenHandsAction | OpenHandsObservation)[],
|
||||
) => {
|
||||
rerender(
|
||||
<Messages messages={newMessages} isAwaitingUserConfirmation={false} />,
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -17,12 +17,12 @@ vi.mock("react-i18next", async () => {
|
||||
t: (key: string) => {
|
||||
// Return a mock translation for the test
|
||||
const translations: Record<string, string> = {
|
||||
"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,
|
||||
|
||||
@ -206,9 +206,8 @@ describe("RepoConnector", () => {
|
||||
"rbren/polaris",
|
||||
"github",
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
"main",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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(<BadgeInput value={["test", "test2"]} onChange={onChangeMock} />);
|
||||
|
||||
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(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
|
||||
|
||||
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(<BadgeInput value={["badge1", "badge2"]} onChange={onChangeMock} />);
|
||||
|
||||
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(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
|
||||
|
||||
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(<BadgeInput value={[]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, " ");
|
||||
expect(onChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
105
frontend/__tests__/microagent-status-indicator.test.tsx
Normal file
105
frontend/__tests__/microagent-status-indicator.test.tsx
Normal file
@ -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(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
prUrl="https://github.com/owner/repo/pull/123"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.CREATING}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("MICROAGENT$STATUS_CREATING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error status", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.ERROR}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("MICROAGENT$STATUS_ERROR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should prioritize PR URL over conversation link when both are provided", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
prUrl="https://github.com/owner/repo/pull/123"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
prUrl="https://gitlab.com/owner/repo/-/merge_requests/456"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
);
|
||||
});
|
||||
});
|
||||
142
frontend/__tests__/parse-pr-url.test.ts
Normal file
142
frontend/__tests__/parse-pr-url.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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(<HomeHeader />);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
|
||||
21
frontend/src/api/memory-service/memory-service.api.ts
Normal file
21
frontend/src/api/memory-service/memory-service.api.ts
Normal file
@ -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<string> {
|
||||
const { data } = await openHands.get<GetPromptResponse>(
|
||||
`/api/conversations/${conversationId}/remember_prompt`,
|
||||
{
|
||||
params: { event_id: eventId },
|
||||
},
|
||||
);
|
||||
return data.prompt;
|
||||
}
|
||||
}
|
||||
@ -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<Conversation> {
|
||||
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<Conversation>(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<ChatMessageProps>) {
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
isDisabled={isCopy}
|
||||
onClick={handleCopyToClipboard}
|
||||
mode={isCopy ? "copied" : "copy"}
|
||||
/>
|
||||
<div className="text-sm break-words">
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
a: anchor,
|
||||
p: paragraph,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{message}
|
||||
</Markdown>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-2.5 -right-2.5",
|
||||
!isHovering ? "hidden" : "flex",
|
||||
"items-center gap-1",
|
||||
)}
|
||||
>
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
isDisabled={isCopy}
|
||||
onClick={handleCopyToClipboard}
|
||||
mode={isCopy ? "copied" : "copy"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-sm break-words flex">
|
||||
<div>
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
a: anchor,
|
||||
p: paragraph,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{message}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</article>
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<div>
|
||||
<ErrorMessage
|
||||
errorId={event.extras.error_id}
|
||||
defaultMessage={event.message}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{renderLikertScale()}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasObservationPair && isOpenHandsAction(event)) {
|
||||
if (hasThoughtProperty(event.args)) {
|
||||
return <ChatMessage type="agent" message={event.args.thought} />;
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={event.args.thought}
|
||||
actions={actions}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return microagentStatus && actions ? (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (isFinishAction(event)) {
|
||||
return (
|
||||
<>
|
||||
<ChatMessage type="agent" message={getEventContent(event).details} />
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={getEventContent(event).details}
|
||||
actions={actions}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{renderLikertScale()}
|
||||
</>
|
||||
);
|
||||
@ -112,8 +164,8 @@ export function EventMessage({
|
||||
const message = parseMessageFromEvent(event);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatMessage type={event.source} message={message}>
|
||||
<div className="flex flex-col self-end">
|
||||
<ChatMessage type={event.source} message={message} actions={actions}>
|
||||
{event.args.image_urls && event.args.image_urls.length > 0 && (
|
||||
<ImageCarousel size="small" images={event.args.image_urls} />
|
||||
)}
|
||||
@ -122,15 +174,26 @@ export function EventMessage({
|
||||
)}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{isAssistantMessage(event) &&
|
||||
event.action === "message" &&
|
||||
renderLikertScale()}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRejectObservation(event)) {
|
||||
return <ChatMessage type="agent" message={event.content} />;
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage type="agent" message={event.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMcpObservation(event)) {
|
||||
|
||||
@ -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<MessagesProps> = 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<number | null>(
|
||||
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<MessagesProps> = 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<MessagesProps> = 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: (
|
||||
<MemoryIcon className="w-[14px] h-[14px] text-white" />
|
||||
),
|
||||
onClick: () => {
|
||||
setSelectedEventId(message.id);
|
||||
setShowLaunchMicroagentModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
/>
|
||||
))}
|
||||
@ -46,6 +230,21 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
{optimisticUserMessage && (
|
||||
<ChatMessage type="user" message={optimisticUserMessage} />
|
||||
)}
|
||||
{conversation?.selected_repository &&
|
||||
showLaunchMicroagentModal &&
|
||||
selectedEventId &&
|
||||
createPortal(
|
||||
<LaunchMicroagentModal
|
||||
onClose={() => setShowLaunchMicroagentModal(false)}
|
||||
onLaunch={handleLaunchMicroagent}
|
||||
selectedRepo={
|
||||
conversation.selected_repository.split("/").pop() || ""
|
||||
}
|
||||
eventId={selectedEventId}
|
||||
isLoading={isPending}
|
||||
/>,
|
||||
document.getElementById("modal-portal-exit") || document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@ -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<string[]>([]);
|
||||
|
||||
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<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
formAction(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
{!runtimeActive && <LoadingMicroagentBody />}
|
||||
{runtimeActive && (
|
||||
<ModalBody className="items-start w-[728px]">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TO_MICROAGENT")}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/prompting/microagents-overview#microagents-overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FaCircleInfo className="text-primary" />
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
data-testid="launch-microagent-modal"
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<label
|
||||
htmlFor="query-input"
|
||||
className="flex flex-col gap-2.5 w-full text-sm"
|
||||
>
|
||||
{t("MICROAGENT$WHAT_TO_REMEMBER")}
|
||||
{promptIsLoading && <LoadingMicroagentTextarea />}
|
||||
{!promptIsLoading && (
|
||||
<textarea
|
||||
required
|
||||
data-testid="query-input"
|
||||
name="query-input"
|
||||
defaultValue={prompt}
|
||||
placeholder={t("MICROAGENT$DESCRIBE_WHAT_TO_ADD")}
|
||||
rows={6}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="target-input"
|
||||
name="target-input"
|
||||
label={t("MICROAGENT$WHERE_TO_PUT")}
|
||||
placeholder={t("MICROAGENT$SELECT_FILE_OR_CUSTOM")}
|
||||
required
|
||||
allowsCustomValue
|
||||
isLoading={microagentsIsLoading}
|
||||
items={
|
||||
microagents?.map((item) => ({
|
||||
key: item,
|
||||
label: item,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="trigger-input"
|
||||
className="flex flex-col gap-2.5 w-full text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TRIGGERS")}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/prompting/microagents-keyword"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FaCircleInfo className="text-primary" />
|
||||
</a>
|
||||
</div>
|
||||
<BadgeInput
|
||||
name="trigger-input"
|
||||
value={triggers}
|
||||
placeholder={t("MICROAGENT$TYPE_TRIGGER_SPACE")}
|
||||
onChange={setTriggers}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<BrandButton type="button" variant="secondary" onClick={onClose}>
|
||||
{t("MICROAGENT$CANCEL")}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={
|
||||
isLoading || promptIsLoading || microagentsIsLoading
|
||||
}
|
||||
>
|
||||
{t("MICROAGENT$LAUNCH")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
)}
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
|
||||
export function LoadingMicroagentBody() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ModalBody>
|
||||
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TO_MICROAGENT")}
|
||||
</h2>
|
||||
<Spinner size="lg" />
|
||||
<p>{t("MICROAGENT$WAIT_FOR_RUNTIME")}</p>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export function LoadingMicroagentTextarea() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<textarea
|
||||
required
|
||||
disabled
|
||||
defaultValue=""
|
||||
placeholder={t("MICROAGENT$LOADING_PROMPT")}
|
||||
rows={6}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { SuccessIndicator } from "../success-indicator";
|
||||
|
||||
interface MicroagentStatusIndicatorProps {
|
||||
status: MicroagentStatus;
|
||||
conversationId?: string;
|
||||
prUrl?: string;
|
||||
}
|
||||
|
||||
export function MicroagentStatusIndicator({
|
||||
status,
|
||||
conversationId,
|
||||
prUrl,
|
||||
}: MicroagentStatusIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.CREATING:
|
||||
return t("MICROAGENT$STATUS_CREATING");
|
||||
case MicroagentStatus.COMPLETED:
|
||||
// If there's a PR URL, show "View your PR" instead of the default completed message
|
||||
return prUrl
|
||||
? t("MICROAGENT$VIEW_YOUR_PR")
|
||||
: t("MICROAGENT$STATUS_COMPLETED");
|
||||
case MicroagentStatus.ERROR:
|
||||
return t("MICROAGENT$STATUS_ERROR");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.CREATING:
|
||||
return <Spinner size="sm" />;
|
||||
case MicroagentStatus.COMPLETED:
|
||||
return <SuccessIndicator status="success" />;
|
||||
case MicroagentStatus.ERROR:
|
||||
return <SuccessIndicator status="error" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const statusText = getStatusText();
|
||||
const shouldShowAsLink = !!conversationId;
|
||||
const shouldShowPRLink = !!prUrl;
|
||||
|
||||
const renderStatusText = () => {
|
||||
if (shouldShowPRLink) {
|
||||
return (
|
||||
<a
|
||||
href={prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{statusText}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowAsLink) {
|
||||
return (
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{statusText}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="underline">{statusText}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-2 p-2 text-sm">
|
||||
{getStatusIcon()}
|
||||
{renderStatusText()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
import toast from "react-hot-toast";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { SuccessIndicator } from "../success-indicator";
|
||||
|
||||
interface ConversationCreatedToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationCreatedToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
}: ConversationCreatedToastProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>
|
||||
{t("MICROAGENT$ADDING_CONTEXT")}
|
||||
<br />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t("MICROAGENT$VIEW_CONVERSATION")}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationFinishedToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationFinishedToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
}: ConversationFinishedToastProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<SuccessIndicator status="success" />
|
||||
<div>
|
||||
{t("MICROAGENT$SUCCESS_PR_READY")}
|
||||
<br />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t("MICROAGENT$VIEW_CONVERSATION")}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationErroredToastProps {
|
||||
errorMessage: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationErroredToast({
|
||||
errorMessage,
|
||||
onClose,
|
||||
}: ConversationErroredToastProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<SuccessIndicator status="error" />
|
||||
<div>{errorMessage}</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const renderConversationCreatedToast = (conversationId: string) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationCreatedToast
|
||||
conversationId={conversationId}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
export const renderConversationFinishedToast = (conversationId: string) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationFinishedToast
|
||||
conversationId={conversationId}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
export const renderConversationErroredToast = (
|
||||
conversationId: string,
|
||||
errorMessage: string,
|
||||
) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationErroredToast
|
||||
errorMessage={errorMessage}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
@ -409,10 +409,7 @@ export function ConversationCard({
|
||||
/>
|
||||
|
||||
{microagentsModalVisible && (
|
||||
<MicroagentsModal
|
||||
onClose={() => setMicroagentsModalVisible(false)}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -13,13 +13,9 @@ import { BrandButton } from "../settings/brand-button";
|
||||
|
||||
interface MicroagentsModalProps {
|
||||
onClose: () => void;
|
||||
conversationId: string | undefined;
|
||||
}
|
||||
|
||||
export function MicroagentsModal({
|
||||
onClose,
|
||||
conversationId,
|
||||
}: MicroagentsModalProps) {
|
||||
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
|
||||
@ -31,11 +27,7 @@ export function MicroagentsModal({
|
||||
isError,
|
||||
refetch,
|
||||
isRefetching,
|
||||
} = useConversationMicroagents({
|
||||
agentState: curAgentState,
|
||||
conversationId,
|
||||
enabled: true,
|
||||
});
|
||||
} = useConversationMicroagents();
|
||||
|
||||
const toggleAgent = (agentName: string) => {
|
||||
setExpandedAgents((prev) => ({
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
|
||||
|
||||
export function HomeHeader() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
@ -28,7 +30,15 @@ export function HomeHeader() {
|
||||
testId="header-launch-button"
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={() => createConversation({})}
|
||||
onClick={() =>
|
||||
createConversation(
|
||||
{},
|
||||
{
|
||||
onSuccess: (data) =>
|
||||
navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
)
|
||||
}
|
||||
isDisabled={isCreatingConversation}
|
||||
>
|
||||
{!isCreatingConversation && t("HOME$LAUNCH_FROM_SCRATCH")}
|
||||
|
||||
@ -1,151 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RepositorySelectionForm } from "./repo-selection-form";
|
||||
|
||||
// Create mock functions
|
||||
const mockUseUserRepositories = vi.fn();
|
||||
const mockUseRepositoryBranches = vi.fn();
|
||||
const mockUseCreateConversation = vi.fn();
|
||||
const mockUseIsCreatingConversation = vi.fn();
|
||||
const mockUseTranslation = vi.fn();
|
||||
const mockUseAuth = vi.fn();
|
||||
|
||||
// Setup default mock returns
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseRepositoryBranches.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseCreateConversation.mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
});
|
||||
|
||||
mockUseIsCreatingConversation.mockReturnValue(false);
|
||||
|
||||
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
|
||||
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
providersAreSet: true,
|
||||
user: {
|
||||
id: 1,
|
||||
login: "testuser",
|
||||
avatar_url: "https://example.com/avatar.png",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
company: "Test Company",
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock the modules
|
||||
vi.mock("#/hooks/query/use-user-repositories", () => ({
|
||||
useUserRepositories: () => mockUseUserRepositories(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-repository-branches", () => ({
|
||||
useRepositoryBranches: () => mockUseRepositoryBranches(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
|
||||
useCreateConversation: () => mockUseCreateConversation(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-creating-conversation", () => ({
|
||||
useIsCreatingConversation: () => mockUseIsCreatingConversation(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}));
|
||||
|
||||
vi.mock("#/context/auth-context", () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
const renderRepositorySelectionForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={vi.fn()} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe("RepositorySelectionForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("shows loading indicator when repositories are being fetched", () => {
|
||||
// Setup loading state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if loading indicator is displayed
|
||||
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
|
||||
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows dropdown when repositories are loaded", () => {
|
||||
// Setup loaded repositories
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if dropdown is displayed
|
||||
expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error message when repository fetch fails", () => {
|
||||
// Setup error state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error("Failed to fetch repositories"),
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if error message is displayed
|
||||
expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
@ -25,6 +26,7 @@ interface RepositorySelectionFormProps {
|
||||
export function RepositorySelectionForm({
|
||||
onRepoSelection,
|
||||
}: RepositorySelectionFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedRepository, setSelectedRepository] =
|
||||
React.useState<GitRepository | null>(null);
|
||||
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
|
||||
@ -208,10 +210,19 @@ export function RepositorySelectionForm({
|
||||
isRepositoriesError
|
||||
}
|
||||
onClick={() =>
|
||||
createConversation({
|
||||
selectedRepository,
|
||||
selected_branch: selectedBranch?.name,
|
||||
})
|
||||
createConversation(
|
||||
{
|
||||
repository: {
|
||||
name: selectedRepository?.full_name || "",
|
||||
gitProvider: selectedRepository?.git_provider || "github",
|
||||
branch: selectedBranch?.name || "main",
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (data) =>
|
||||
navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{!isCreatingConversation && "Launch"}
|
||||
|
||||
@ -3,9 +3,7 @@ import { SuggestedTask } from "./task.types";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { TaskIssueNumber } from "./task-issue-number";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
|
||||
const getTaskTypeMap = (
|
||||
@ -23,28 +21,19 @@ interface TaskCardProps {
|
||||
|
||||
export function TaskCard({ task }: TaskCardProps) {
|
||||
const { setOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { data: repositories } = useUserRepositories();
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
const isCreatingConversation = useIsCreatingConversation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getRepo = (repo: string, git_provider: Provider) => {
|
||||
const selectedRepo = repositories?.find(
|
||||
(repository) =>
|
||||
repository.full_name === repo &&
|
||||
repository.git_provider === git_provider,
|
||||
);
|
||||
|
||||
return selectedRepo;
|
||||
};
|
||||
|
||||
const handleLaunchConversation = () => {
|
||||
const repo = getRepo(task.repo, task.git_provider);
|
||||
setOptimisticUserMessage(t("TASK$ADDRESSING_TASK"));
|
||||
|
||||
return createConversation({
|
||||
selectedRepository: repo,
|
||||
suggested_task: task,
|
||||
repository: {
|
||||
name: task.repo,
|
||||
gitProvider: task.git_provider,
|
||||
},
|
||||
suggestedTask: task,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@heroui/react";
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OptionalTag } from "./optional-tag";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
@ -12,9 +13,12 @@ interface SettingsDropdownInputProps {
|
||||
placeholder?: string;
|
||||
showOptionalTag?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
defaultSelectedKey?: string;
|
||||
selectedKey?: string;
|
||||
isClearable?: boolean;
|
||||
allowsCustomValue?: boolean;
|
||||
required?: boolean;
|
||||
onSelectionChange?: (key: React.Key | null) => void;
|
||||
onInputChange?: (value: string) => void;
|
||||
defaultFilter?: (textValue: string, inputValue: string) => boolean;
|
||||
@ -29,13 +33,17 @@ export function SettingsDropdownInput({
|
||||
placeholder,
|
||||
showOptionalTag,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
defaultSelectedKey,
|
||||
selectedKey,
|
||||
isClearable,
|
||||
allowsCustomValue,
|
||||
required,
|
||||
onSelectionChange,
|
||||
onInputChange,
|
||||
defaultFilter,
|
||||
}: SettingsDropdownInputProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
|
||||
{label && (
|
||||
@ -54,8 +62,11 @@ export function SettingsDropdownInput({
|
||||
onSelectionChange={onSelectionChange}
|
||||
onInputChange={onInputChange}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
placeholder={placeholder}
|
||||
isDisabled={isDisabled || isLoading}
|
||||
isLoading={isLoading}
|
||||
placeholder={isLoading ? t("HOME$LOADING") : placeholder}
|
||||
allowsCustomValue={allowsCustomValue}
|
||||
isRequired={required}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
|
||||
|
||||
21
frontend/src/components/shared/badge.tsx
Normal file
21
frontend/src/components/shared/badge.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface BrandBadgeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BrandBadge({
|
||||
children,
|
||||
className,
|
||||
}: React.PropsWithChildren<BrandBadgeProps>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm leading-4 text-[#0D0F11] font-semibold tracking-tighter bg-primary p-1 rounded-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -27,7 +27,7 @@ export function CopyToClipboardButton({
|
||||
aria-label={t(
|
||||
mode === "copy" ? I18nKey.BUTTON$COPY : I18nKey.BUTTON$COPIED,
|
||||
)}
|
||||
className="button-base p-1 absolute top-1 right-1"
|
||||
className="button-base p-1 cursor-pointer"
|
||||
>
|
||||
{mode === "copy" && <CopyIcon width={15} height={15} />}
|
||||
{mode === "copied" && <CheckmarkIcon width={15} height={15} />}
|
||||
|
||||
75
frontend/src/components/shared/inputs/badge-input.tsx
Normal file
75
frontend/src/components/shared/inputs/badge-input.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { FaX } from "react-icons/fa6";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BrandBadge } from "../badge";
|
||||
|
||||
interface BadgeInputProps {
|
||||
name?: string;
|
||||
value: string[];
|
||||
placeholder?: string;
|
||||
onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
export function BadgeInput({
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: BadgeInputProps) {
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// If pressing Backspace with empty input, remove the last badge
|
||||
if (e.key === "Backspace" && inputValue === "" && value.length > 0) {
|
||||
const newBadges = [...value];
|
||||
newBadges.pop();
|
||||
onChange(newBadges);
|
||||
return;
|
||||
}
|
||||
|
||||
// If pressing Space or Enter with non-empty input, add a new badge
|
||||
if (e.key === " " && inputValue.trim() !== "") {
|
||||
e.preventDefault();
|
||||
const newBadge = inputValue.trim();
|
||||
onChange([...value, newBadge]);
|
||||
setInputValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeBadge = (indexToRemove: number) => {
|
||||
onChange(value.filter((_, index) => index !== indexToRemove));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] rounded w-full p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"flex flex-wrap items-center gap-2",
|
||||
)}
|
||||
>
|
||||
{value.map((badge, index) => (
|
||||
<div key={index}>
|
||||
<BrandBadge className="flex items-center gap-0.5">
|
||||
{badge}
|
||||
<button
|
||||
data-testid="remove-button"
|
||||
type="button"
|
||||
onClick={() => removeBadge(index)}
|
||||
>
|
||||
<FaX className="w-3 h-3 text-black" />
|
||||
</button>
|
||||
</BrandBadge>
|
||||
</div>
|
||||
))}
|
||||
<input
|
||||
data-testid={name || "badge-input"}
|
||||
name={name}
|
||||
value={inputValue}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-grow outline-none bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
315
frontend/src/context/conversation-subscriptions-provider.tsx
Normal file
315
frontend/src/context/conversation-subscriptions-provider.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { OpenHandsParsedEvent } from "#/types/core";
|
||||
import {
|
||||
isOpenHandsEvent,
|
||||
isAgentStateChangeObservation,
|
||||
isStatusUpdate,
|
||||
} from "#/types/core/guards";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import {
|
||||
renderConversationErroredToast,
|
||||
renderConversationCreatedToast,
|
||||
renderConversationFinishedToast,
|
||||
} from "#/components/features/chat/microagent/microagent-status-toast";
|
||||
|
||||
interface ConversationSocket {
|
||||
socket: Socket;
|
||||
isConnected: boolean;
|
||||
events: OpenHandsParsedEvent[];
|
||||
}
|
||||
|
||||
interface ConversationSubscriptionsContextType {
|
||||
activeConversationIds: string[];
|
||||
subscribeToConversation: (options: {
|
||||
conversationId: string;
|
||||
sessionApiKey: string | null;
|
||||
providersSet: ("github" | "gitlab" | "bitbucket")[];
|
||||
baseUrl: string;
|
||||
onEvent?: (event: unknown, conversationId: string) => void;
|
||||
}) => void;
|
||||
unsubscribeFromConversation: (conversationId: string) => void;
|
||||
isSubscribedToConversation: (conversationId: string) => boolean;
|
||||
getEventsForConversation: (conversationId: string) => OpenHandsParsedEvent[];
|
||||
}
|
||||
|
||||
const ConversationSubscriptionsContext =
|
||||
createContext<ConversationSubscriptionsContextType>({
|
||||
activeConversationIds: [],
|
||||
subscribeToConversation: () => {
|
||||
throw new Error("ConversationSubscriptionsProvider not initialized");
|
||||
},
|
||||
unsubscribeFromConversation: () => {
|
||||
throw new Error("ConversationSubscriptionsProvider not initialized");
|
||||
},
|
||||
isSubscribedToConversation: () => false,
|
||||
getEventsForConversation: () => [],
|
||||
});
|
||||
|
||||
const isErrorEvent = (
|
||||
event: unknown,
|
||||
): event is { error: true; message: string } =>
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"error" in event &&
|
||||
event.error === true &&
|
||||
"message" in event &&
|
||||
typeof event.message === "string";
|
||||
|
||||
const isAgentStatusError = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
isOpenHandsEvent(event) &&
|
||||
isAgentStateChangeObservation(event) &&
|
||||
event.extras.agent_state === AgentState.ERROR;
|
||||
|
||||
export function ConversationSubscriptionsProvider({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const [activeConversationIds, setActiveConversationIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [conversationSockets, setConversationSockets] = useState<
|
||||
Record<string, ConversationSocket>
|
||||
>({});
|
||||
const eventHandlersRef = useRef<Record<string, (event: unknown) => void>>({});
|
||||
|
||||
// Cleanup function to remove all subscriptions when component unmounts
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Store the current sockets in a local variable to avoid closure issues
|
||||
const socketsToDisconnect = { ...conversationSockets };
|
||||
|
||||
Object.values(socketsToDisconnect).forEach((socketData) => {
|
||||
if (socketData.socket) {
|
||||
socketData.socket.removeAllListeners();
|
||||
socketData.socket.disconnect();
|
||||
}
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unsubscribeFromConversation = useCallback(
|
||||
(conversationId: string) => {
|
||||
// Get a local reference to the socket data to avoid race conditions
|
||||
const socketData = conversationSockets[conversationId];
|
||||
|
||||
if (socketData) {
|
||||
const { socket } = socketData;
|
||||
const handler = eventHandlersRef.current[conversationId];
|
||||
|
||||
if (socket) {
|
||||
if (handler) {
|
||||
socket.off("oh_event", handler);
|
||||
}
|
||||
socket.removeAllListeners();
|
||||
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];
|
||||
}
|
||||
},
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const subscribeToConversation = useCallback(
|
||||
(options: {
|
||||
conversationId: string;
|
||||
sessionApiKey: string | null;
|
||||
providersSet: ("github" | "gitlab" | "bitbucket")[];
|
||||
baseUrl: string;
|
||||
onEvent?: (event: unknown, conversationId: string) => void;
|
||||
}) => {
|
||||
const { conversationId, sessionApiKey, providersSet, baseUrl, onEvent } =
|
||||
options;
|
||||
|
||||
// If already subscribed, don't create a new subscription
|
||||
if (conversationSockets[conversationId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleOhEvent = (event: unknown) => {
|
||||
// Call the custom event handler if provided
|
||||
if (onEvent) {
|
||||
onEvent(event, conversationId);
|
||||
}
|
||||
|
||||
// Update the events for this subscription
|
||||
if (isOpenHandsEvent(event)) {
|
||||
setConversationSockets((prev) => {
|
||||
// Make sure the conversation still exists in our state
|
||||
if (!prev[conversationId]) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
...prev[conversationId],
|
||||
events: [...(prev[conversationId]?.events || []), event],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Handle error events
|
||||
if (isErrorEvent(event) || isAgentStatusError(event)) {
|
||||
renderConversationErroredToast(
|
||||
conversationId,
|
||||
isErrorEvent(event)
|
||||
? event.message
|
||||
: "Unknown error, please try again",
|
||||
);
|
||||
} else if (isStatusUpdate(event)) {
|
||||
if (event.type === "info" && event.id === "STATUS$STARTING_RUNTIME") {
|
||||
renderConversationCreatedToast(conversationId);
|
||||
}
|
||||
} else if (
|
||||
isOpenHandsEvent(event) &&
|
||||
isAgentStateChangeObservation(event)
|
||||
) {
|
||||
if (event.extras.agent_state === AgentState.FINISHED) {
|
||||
renderConversationFinishedToast(conversationId);
|
||||
unsubscribeFromConversation(conversationId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Store the event handler in ref for cleanup
|
||||
eventHandlersRef.current[conversationId] = handleOhEvent;
|
||||
|
||||
try {
|
||||
// Create socket connection
|
||||
const socket = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
query: {
|
||||
conversation_id: conversationId,
|
||||
session_api_key: sessionApiKey,
|
||||
providers_set: providersSet,
|
||||
},
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
});
|
||||
|
||||
// Set up event listeners
|
||||
socket.on("connect", () => {
|
||||
setConversationSockets((prev) => {
|
||||
// Make sure the conversation still exists in our state
|
||||
if (!prev[conversationId]) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
...prev[conversationId],
|
||||
isConnected: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("connect_error", (error) => {
|
||||
console.warn(
|
||||
`Socket for conversation ${conversationId} CONNECTION ERROR:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.warn(
|
||||
`Socket for conversation ${conversationId} DISCONNECTED! Reason:`,
|
||||
reason,
|
||||
);
|
||||
setConversationSockets((prev) => {
|
||||
// Make sure the conversation still exists in our state
|
||||
if (!prev[conversationId]) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
...prev[conversationId],
|
||||
isConnected: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("oh_event", handleOhEvent);
|
||||
|
||||
// Add the socket to our state first
|
||||
setConversationSockets((prev) => ({
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
socket,
|
||||
isConnected: socket.connected,
|
||||
events: [],
|
||||
},
|
||||
}));
|
||||
|
||||
// Then add to active conversation IDs
|
||||
setActiveConversationIds((prev) =>
|
||||
prev.includes(conversationId) ? prev : [...prev, conversationId],
|
||||
);
|
||||
} catch (error) {
|
||||
// Clean up the event handler if there was an error
|
||||
delete eventHandlersRef.current[conversationId];
|
||||
}
|
||||
},
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const isSubscribedToConversation = useCallback(
|
||||
(conversationId: string) => !!conversationSockets[conversationId],
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const getEventsForConversation = useCallback(
|
||||
(conversationId: string) =>
|
||||
conversationSockets[conversationId]?.events || [],
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
activeConversationIds,
|
||||
subscribeToConversation,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
getEventsForConversation,
|
||||
}),
|
||||
[
|
||||
activeConversationIds,
|
||||
subscribeToConversation,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
getEventsForConversation,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConversationSubscriptionsContext.Provider value={value}>
|
||||
{children}
|
||||
</ConversationSubscriptionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConversationSubscriptions() {
|
||||
return useContext(ConversationSubscriptionsContext);
|
||||
}
|
||||
@ -328,6 +328,7 @@ export function WsClientProvider({
|
||||
transports: ["websocket"],
|
||||
query,
|
||||
});
|
||||
|
||||
sio.on("connect", handleConnect);
|
||||
sio.on("oh_event", handleMessage);
|
||||
sio.on("connect_error", handleError);
|
||||
|
||||
@ -67,6 +67,7 @@ prepareApp().then(() =>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydratedRouter />
|
||||
<PosthogInit />
|
||||
<div id="modal-portal-exit" />
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
|
||||
@ -1,58 +1,47 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import posthog from "posthog-js";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { setInitialPrompt } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
interface CreateConversationVariables {
|
||||
query?: string;
|
||||
repository?: {
|
||||
name: string;
|
||||
gitProvider: Provider;
|
||||
branch?: string;
|
||||
};
|
||||
suggestedTask?: SuggestedTask;
|
||||
conversationInstructions?: string;
|
||||
}
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { selectedRepository, files, replayJson } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["create-conversation"],
|
||||
mutationFn: async (variables: {
|
||||
q?: string;
|
||||
selectedRepository?: GitRepository | null;
|
||||
selected_branch?: string;
|
||||
suggested_task?: SuggestedTask;
|
||||
}) => {
|
||||
if (variables.q) dispatch(setInitialPrompt(variables.q));
|
||||
mutationFn: async (variables: CreateConversationVariables) => {
|
||||
const { query, repository, suggestedTask, conversationInstructions } =
|
||||
variables;
|
||||
|
||||
return OpenHands.createConversation(
|
||||
variables.selectedRepository
|
||||
? variables.selectedRepository.full_name
|
||||
: undefined,
|
||||
variables.selectedRepository
|
||||
? variables.selectedRepository.git_provider
|
||||
: undefined,
|
||||
variables.q,
|
||||
files,
|
||||
replayJson || undefined,
|
||||
variables.suggested_task || undefined,
|
||||
variables.selected_branch,
|
||||
repository?.name,
|
||||
repository?.gitProvider,
|
||||
query,
|
||||
suggestedTask,
|
||||
repository?.branch,
|
||||
conversationInstructions,
|
||||
);
|
||||
},
|
||||
onSuccess: async ({ conversation_id: conversationId }, { q }) => {
|
||||
onSuccess: async (_, { query, repository }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q?.length,
|
||||
has_repository: !!selectedRepository,
|
||||
has_files: files.length > 0,
|
||||
has_replay_json: !!replayJson,
|
||||
query_character_length: query?.length,
|
||||
has_repository: !!repository,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,19 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSelector } from "react-redux";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
interface UseConversationMicroagentsOptions {
|
||||
agentState?: AgentState;
|
||||
conversationId: string | undefined;
|
||||
enabled?: boolean;
|
||||
}
|
||||
export const useConversationMicroagents = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
export const useConversationMicroagents = ({
|
||||
agentState,
|
||||
conversationId,
|
||||
enabled = true,
|
||||
}: UseConversationMicroagentsOptions) =>
|
||||
useQuery({
|
||||
return useQuery({
|
||||
queryKey: ["conversation", conversationId, "microagents"],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) {
|
||||
@ -24,9 +20,9 @@ export const useConversationMicroagents = ({
|
||||
},
|
||||
enabled:
|
||||
!!conversationId &&
|
||||
enabled &&
|
||||
agentState !== AgentState.LOADING &&
|
||||
agentState !== AgentState.INIT,
|
||||
curAgentState !== AgentState.LOADING &&
|
||||
curAgentState !== AgentState.INIT,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useGetMicroagentPrompt = ({ eventId }: { eventId: number }) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["conversation", "remember_prompt", conversationId, eventId],
|
||||
queryFn: () => OpenHands.getMicroagentPrompt(conversationId, eventId),
|
||||
});
|
||||
};
|
||||
15
frontend/src/hooks/query/use-get-microagents.ts
Normal file
15
frontend/src/hooks/query/use-get-microagents.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
export const useGetMicroagents = (microagentDirectory: string) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["files", "microagents", conversationId, microagentDirectory],
|
||||
queryFn: () => FileService.getFiles(conversationId!, microagentDirectory),
|
||||
enabled: !!conversationId,
|
||||
select: (data) =>
|
||||
data.map((fileName) => fileName.replace(microagentDirectory, "")),
|
||||
});
|
||||
};
|
||||
15
frontend/src/hooks/query/use-microagent-prompt.ts
Normal file
15
frontend/src/hooks/query/use-microagent-prompt.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MemoryService } from "#/api/memory-service/memory-service.api";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
|
||||
export const useMicroagentPrompt = (eventId: number) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["memory", "prompt", conversationId, eventId],
|
||||
queryFn: () => MemoryService.getPrompt(conversationId!, eventId),
|
||||
enabled: !!conversationId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const useCreateConversationAndSubscribeMultiple = () => {
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
const { providers } = useUserProviders();
|
||||
const {
|
||||
subscribeToConversation,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
activeConversationIds,
|
||||
} = useConversationSubscriptions();
|
||||
|
||||
const createConversationAndSubscribe = React.useCallback(
|
||||
({
|
||||
query,
|
||||
conversationInstructions,
|
||||
repository,
|
||||
onSuccessCallback,
|
||||
onEventCallback,
|
||||
}: {
|
||||
query: string;
|
||||
conversationInstructions: string;
|
||||
repository: {
|
||||
name: string;
|
||||
branch: string;
|
||||
gitProvider: Provider;
|
||||
};
|
||||
onSuccessCallback?: (conversationId: string) => void;
|
||||
onEventCallback?: (event: unknown, conversationId: string) => void;
|
||||
}) => {
|
||||
createConversation(
|
||||
{
|
||||
query,
|
||||
conversationInstructions,
|
||||
repository,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
let baseUrl = "";
|
||||
if (data?.url && !data.url.startsWith("/")) {
|
||||
baseUrl = new URL(data.url).host;
|
||||
} else {
|
||||
baseUrl =
|
||||
(import.meta.env.VITE_BACKEND_BASE_URL as string | undefined) ||
|
||||
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);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[createConversation, subscribeToConversation, providers],
|
||||
);
|
||||
|
||||
return {
|
||||
createConversationAndSubscribe,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
activeConversationIds,
|
||||
isPending,
|
||||
};
|
||||
};
|
||||
@ -1,5 +1,26 @@
|
||||
// this file generate by script, don't modify it manually!!!
|
||||
export enum I18nKey {
|
||||
MICROAGENT$NO_REPOSITORY_FOUND = "MICROAGENT$NO_REPOSITORY_FOUND",
|
||||
MICROAGENT$ADD_TO_MICROAGENT = "MICROAGENT$ADD_TO_MICROAGENT",
|
||||
MICROAGENT$WHAT_TO_ADD = "MICROAGENT$WHAT_TO_ADD",
|
||||
MICROAGENT$WHERE_TO_PUT = "MICROAGENT$WHERE_TO_PUT",
|
||||
MICROAGENT$ADD_TRIGGER = "MICROAGENT$ADD_TRIGGER",
|
||||
MICROAGENT$WHAT_TO_REMEMBER = "MICROAGENT$WHAT_TO_REMEMBER",
|
||||
MICROAGENT$ADD_TRIGGERS = "MICROAGENT$ADD_TRIGGERS",
|
||||
MICROAGENT$WAIT_FOR_RUNTIME = "MICROAGENT$WAIT_FOR_RUNTIME",
|
||||
MICROAGENT$ADDING_CONTEXT = "MICROAGENT$ADDING_CONTEXT",
|
||||
MICROAGENT$VIEW_CONVERSATION = "MICROAGENT$VIEW_CONVERSATION",
|
||||
MICROAGENT$SUCCESS_PR_READY = "MICROAGENT$SUCCESS_PR_READY",
|
||||
MICROAGENT$STATUS_CREATING = "MICROAGENT$STATUS_CREATING",
|
||||
MICROAGENT$STATUS_COMPLETED = "MICROAGENT$STATUS_COMPLETED",
|
||||
MICROAGENT$STATUS_ERROR = "MICROAGENT$STATUS_ERROR",
|
||||
MICROAGENT$VIEW_YOUR_PR = "MICROAGENT$VIEW_YOUR_PR",
|
||||
MICROAGENT$DESCRIBE_WHAT_TO_ADD = "MICROAGENT$DESCRIBE_WHAT_TO_ADD",
|
||||
MICROAGENT$SELECT_FILE_OR_CUSTOM = "MICROAGENT$SELECT_FILE_OR_CUSTOM",
|
||||
MICROAGENT$TYPE_TRIGGER_SPACE = "MICROAGENT$TYPE_TRIGGER_SPACE",
|
||||
MICROAGENT$LOADING_PROMPT = "MICROAGENT$LOADING_PROMPT",
|
||||
MICROAGENT$CANCEL = "MICROAGENT$CANCEL",
|
||||
MICROAGENT$LAUNCH = "MICROAGENT$LAUNCH",
|
||||
STATUS$WEBSOCKET_CLOSED = "STATUS$WEBSOCKET_CLOSED",
|
||||
HOME$LAUNCH_FROM_SCRATCH = "HOME$LAUNCH_FROM_SCRATCH",
|
||||
HOME$READ_THIS = "HOME$READ_THIS",
|
||||
|
||||
@ -1,4 +1,341 @@
|
||||
{
|
||||
"MICROAGENT$NO_REPOSITORY_FOUND": {
|
||||
"en": "No repository found to launch microagent",
|
||||
"ja": "マイクロエージェントを起動するためのリポジトリが見つかりません",
|
||||
"zh-CN": "未找到启动微代理的存储库",
|
||||
"zh-TW": "未找到啟動微代理的存儲庫",
|
||||
"ko-KR": "마이크로에이전트를 시작할 저장소를 찾을 수 없습니다",
|
||||
"no": "Ingen repository funnet for å starte mikroagent",
|
||||
"it": "Nessun repository trovato per avviare il microagente",
|
||||
"pt": "Nenhum repositório encontrado para iniciar o microagente",
|
||||
"es": "No se encontró ningún repositorio para iniciar el microagente",
|
||||
"ar": "لم يتم العثور على مستودع لإطلاق الوكيل المصغر",
|
||||
"fr": "Aucun dépôt trouvé pour lancer le micro-agent",
|
||||
"tr": "Mikro ajanı başlatmak için depo bulunamadı",
|
||||
"de": "Kein Repository gefunden, um Microagent zu starten",
|
||||
"uk": "Не знайдено репозиторій для запуску мікроагента"
|
||||
},
|
||||
|
||||
"MICROAGENT$ADD_TO_MICROAGENT": {
|
||||
"en": "Add to Microagent",
|
||||
"ja": "マイクロエージェントに追加",
|
||||
"zh-CN": "添加到微代理",
|
||||
"zh-TW": "添加到微代理",
|
||||
"ko-KR": "마이크로에이전트에 추가",
|
||||
"no": "Legg til i mikroagent",
|
||||
"it": "Aggiungi al microagente",
|
||||
"pt": "Adicionar ao microagente",
|
||||
"es": "Añadir al microagente",
|
||||
"ar": "إضافة إلى الوكيل المصغر",
|
||||
"fr": "Ajouter au micro-agent",
|
||||
"tr": "Mikro ajana ekle",
|
||||
"de": "Zum Microagent hinzufügen",
|
||||
"uk": "Додати до мікроагента"
|
||||
},
|
||||
"MICROAGENT$WHAT_TO_ADD": {
|
||||
"en": "What would you like to add to the Microagent?",
|
||||
"ja": "マイクロエージェントに何を追加しますか?",
|
||||
"zh-CN": "您想添加什么到微代理?",
|
||||
"zh-TW": "您想添加什麼到微代理?",
|
||||
"ko-KR": "마이크로에이전트에 무엇을 추가하시겠습니까?",
|
||||
"no": "Hva vil du legge til i mikroagenten?",
|
||||
"it": "Cosa vorresti aggiungere al microagente?",
|
||||
"pt": "O que você gostaria de adicionar ao microagente?",
|
||||
"es": "¿Qué te gustaría añadir al microagente?",
|
||||
"ar": "ماذا تريد أن تضيف إلى الوكيل المصغر؟",
|
||||
"fr": "Que souhaitez-vous ajouter au micro-agent ?",
|
||||
"tr": "Mikro ajana ne eklemek istersiniz?",
|
||||
"de": "Was möchten Sie zum Microagent hinzufügen?",
|
||||
"uk": "Що ви хочете додати до мікроагента?"
|
||||
},
|
||||
"MICROAGENT$WHERE_TO_PUT": {
|
||||
"en": "Where should we put it?",
|
||||
"ja": "どこに配置しますか?",
|
||||
"zh-CN": "我们应该把它放在哪里?",
|
||||
"zh-TW": "我們應該把它放在哪裡?",
|
||||
"ko-KR": "어디에 넣을까요?",
|
||||
"no": "Hvor skal vi plassere det?",
|
||||
"it": "Dove dovremmo metterlo?",
|
||||
"pt": "Onde devemos colocá-lo?",
|
||||
"es": "¿Dónde deberíamos ponerlo?",
|
||||
"ar": "أين يجب أن نضعه؟",
|
||||
"fr": "Où devons-nous le mettre ?",
|
||||
"tr": "Nereye koyalım?",
|
||||
"de": "Wo sollen wir es platzieren?",
|
||||
"uk": "Куди ми повинні його помістити?"
|
||||
},
|
||||
"MICROAGENT$ADD_TRIGGER": {
|
||||
"en": "Add a trigger for the microagent",
|
||||
"ja": "マイクロエージェントのトリガーを追加",
|
||||
"zh-CN": "为微代理添加触发器",
|
||||
"zh-TW": "為微代理添加觸發器",
|
||||
"ko-KR": "마이크로에이전트의 트리거 추가",
|
||||
"no": "Legg til en utløser for mikroagenten",
|
||||
"it": "Aggiungi un trigger per il microagente",
|
||||
"pt": "Adicionar um gatilho para o microagente",
|
||||
"es": "Añadir un disparador para el microagente",
|
||||
"ar": "إضافة مشغل للوكيل المصغر",
|
||||
"fr": "Ajouter un déclencheur pour le micro-agent",
|
||||
"tr": "Mikro ajan için bir tetikleyici ekleyin",
|
||||
"de": "Fügen Sie einen Auslöser für den Microagent hinzu",
|
||||
"uk": "Додати тригер для мікроагента"
|
||||
},
|
||||
"MICROAGENT$WHAT_TO_REMEMBER": {
|
||||
"en": "What would you like your microagent to remember?",
|
||||
"ja": "マイクロエージェントに何を覚えさせたいですか?",
|
||||
"zh-CN": "您希望您的微代理记住什么?",
|
||||
"zh-TW": "您希望您的微代理記住什麼?",
|
||||
"ko-KR": "마이크로에이전트가 무엇을 기억하기를 원하시나요?",
|
||||
"no": "Hva vil du at mikroagenten din skal huske?",
|
||||
"it": "Cosa vorresti che il tuo microagente ricordasse?",
|
||||
"pt": "O que você gostaria que seu microagente lembrasse?",
|
||||
"es": "¿Qué te gustaría que tu microagente recordara?",
|
||||
"ar": "ماذا تريد أن يتذكر وكيلك المصغر؟",
|
||||
"fr": "Que souhaitez-vous que votre micro-agent se souvienne ?",
|
||||
"tr": "Mikro ajanınızın neyi hatırlamasını istersiniz?",
|
||||
"de": "Was soll sich Ihr Microagent merken?",
|
||||
"uk": "Що ви хочете, щоб ваш мікроагент запам'ятав?"
|
||||
},
|
||||
"MICROAGENT$ADD_TRIGGERS": {
|
||||
"en": "Add triggers for the microagent",
|
||||
"ja": "マイクロエージェントのトリガーを追加",
|
||||
"zh-CN": "为微代理添加触发器",
|
||||
"zh-TW": "為微代理添加觸發器",
|
||||
"ko-KR": "마이크로에이전트의 트리거 추가",
|
||||
"no": "Legg til utløsere for mikroagenten",
|
||||
"it": "Aggiungi trigger per il microagente",
|
||||
"pt": "Adicionar gatilhos para o microagente",
|
||||
"es": "Añadir disparadores para el microagente",
|
||||
"ar": "إضافة مشغلات للوكيل المصغر",
|
||||
"fr": "Ajouter des déclencheurs pour le micro-agent",
|
||||
"tr": "Mikro ajan için tetikleyiciler ekleyin",
|
||||
"de": "Auslöser für den Microagent hinzufügen",
|
||||
"uk": "Додати тригери для мікроагента"
|
||||
},
|
||||
"MICROAGENT$WAIT_FOR_RUNTIME": {
|
||||
"en": "Please wait for the runtime to be active.",
|
||||
"ja": "ランタイムがアクティブになるまでお待ちください。",
|
||||
"zh-CN": "请等待运行时激活。",
|
||||
"zh-TW": "請等待運行時激活。",
|
||||
"ko-KR": "런타임이 활성화될 때까지 기다려주세요.",
|
||||
"no": "Vennligst vent til kjøretidsmiljøet er aktivt.",
|
||||
"it": "Attendere che il runtime sia attivo.",
|
||||
"pt": "Aguarde até que o tempo de execução esteja ativo.",
|
||||
"es": "Por favor, espere a que el tiempo de ejecución esté activo.",
|
||||
"ar": "يرجى الانتظار حتى يصبح وقت التشغيل نشطًا.",
|
||||
"fr": "Veuillez attendre que le runtime soit actif.",
|
||||
"tr": "Lütfen çalışma zamanının aktif olmasını bekleyin.",
|
||||
"de": "Bitte warten Sie, bis die Laufzeitumgebung aktiv ist.",
|
||||
"uk": "Будь ласка, зачекайте, поки середовище виконання стане активним."
|
||||
},
|
||||
"MICROAGENT$ADDING_CONTEXT": {
|
||||
"en": "OpenHands is adding this new context to your respository. We'll let you know when the pull request is ready.",
|
||||
"ja": "OpenHandsはこの新しいコンテキストをあなたのリポジトリに追加しています。プルリクエストの準備ができたらお知らせします。",
|
||||
"zh-CN": "OpenHands正在将此新上下文添加到您的存储库中。拉取请求准备好后,我们会通知您。",
|
||||
"zh-TW": "OpenHands正在將此新上下文添加到您的存儲庫中。拉取請求準備好後,我們會通知您。",
|
||||
"ko-KR": "OpenHands가 이 새로운 컨텍스트를 저장소에 추가하고 있습니다. 풀 리퀘스트가 준비되면 알려드리겠습니다.",
|
||||
"no": "OpenHands legger til denne nye konteksten i ditt repository. Vi gir deg beskjed når pull-forespørselen er klar.",
|
||||
"it": "OpenHands sta aggiungendo questo nuovo contesto al tuo repository. Ti faremo sapere quando la pull request sarà pronta.",
|
||||
"pt": "OpenHands está adicionando este novo contexto ao seu repositório. Avisaremos quando o pull request estiver pronto.",
|
||||
"es": "OpenHands está añadiendo este nuevo contexto a tu repositorio. Te avisaremos cuando la solicitud de extracción esté lista.",
|
||||
"ar": "يقوم OpenHands بإضافة هذا السياق الجديد إلى مستودعك. سنعلمك عندما يكون طلب السحب جاهزًا.",
|
||||
"fr": "OpenHands ajoute ce nouveau contexte à votre dépôt. Nous vous informerons lorsque la pull request sera prête.",
|
||||
"tr": "OpenHands bu yeni bağlamı deponuza ekliyor. Çekme isteği hazır olduğunda size haber vereceğiz.",
|
||||
"de": "OpenHands fügt diesen neuen Kontext zu Ihrem Repository hinzu. Wir informieren Sie, wenn der Pull Request bereit ist.",
|
||||
"uk": "OpenHands додає цей новий контекст до вашого репозиторію. Ми повідомимо вас, коли запит на витягування буде готовий."
|
||||
},
|
||||
"MICROAGENT$VIEW_CONVERSATION": {
|
||||
"en": "View Conversation",
|
||||
"ja": "会話を表示",
|
||||
"zh-CN": "查看对话",
|
||||
"zh-TW": "查看對話",
|
||||
"ko-KR": "대화 보기",
|
||||
"no": "Vis samtale",
|
||||
"it": "Visualizza conversazione",
|
||||
"pt": "Ver conversa",
|
||||
"es": "Ver conversación",
|
||||
"ar": "عرض المحادثة",
|
||||
"fr": "Voir la conversation",
|
||||
"tr": "Konuşmayı Görüntüle",
|
||||
"de": "Konversation anzeigen",
|
||||
"uk": "Переглянути розмову"
|
||||
},
|
||||
"MICROAGENT$SUCCESS_PR_READY": {
|
||||
"en": "Success! Your microagent pull request is ready.",
|
||||
"ja": "成功!マイクロエージェントのプルリクエストの準備ができました。",
|
||||
"zh-CN": "成功!您的微代理拉取请求已准备就绪。",
|
||||
"zh-TW": "成功!您的微代理拉取請求已準備就緒。",
|
||||
"ko-KR": "성공! 마이크로에이전트 풀 리퀘스트가 준비되었습니다.",
|
||||
"no": "Suksess! Din mikroagent pull request er klar.",
|
||||
"it": "Successo! La tua pull request del microagente è pronta.",
|
||||
"pt": "Sucesso! Seu pull request de microagente está pronto.",
|
||||
"es": "¡Éxito! Tu solicitud de extracción de microagente está lista.",
|
||||
"ar": "نجاح! طلب سحب الوكيل المصغر الخاص بك جاهز.",
|
||||
"fr": "Succès ! Votre pull request de micro-agent est prête.",
|
||||
"tr": "Başarılı! Mikro ajan çekme isteğiniz hazır.",
|
||||
"de": "Erfolg! Ihr Microagent Pull Request ist bereit.",
|
||||
"uk": "Успіх! Ваш запит на витягування мікроагента готовий."
|
||||
},
|
||||
"MICROAGENT$STATUS_CREATING": {
|
||||
"en": "Modifying microagent...",
|
||||
"ja": "マイクロエージェントを変更中...",
|
||||
"zh-CN": "正在修改微代理...",
|
||||
"zh-TW": "正在修改微代理...",
|
||||
"ko-KR": "마이크로에이전트 수정 중...",
|
||||
"no": "Endrer mikroagent...",
|
||||
"it": "Modifica del microagente in corso...",
|
||||
"pt": "Modificando microagente...",
|
||||
"es": "Modificando microagente...",
|
||||
"ar": "تعديل الوكيل المصغر...",
|
||||
"fr": "Modification du micro-agent en cours...",
|
||||
"tr": "Mikro ajan değiştiriliyor...",
|
||||
"de": "Microagent wird geändert...",
|
||||
"uk": "Зміна мікроагента..."
|
||||
},
|
||||
"MICROAGENT$STATUS_COMPLETED": {
|
||||
"en": "View microagent update",
|
||||
"ja": "マイクロエージェントの更新を表示",
|
||||
"zh-CN": "查看微代理更新",
|
||||
"zh-TW": "查看微代理更新",
|
||||
"ko-KR": "마이크로에이전트 업데이트 보기",
|
||||
"no": "Vis mikroagent oppdatering",
|
||||
"it": "Visualizza aggiornamento microagente",
|
||||
"pt": "Ver atualização do microagente",
|
||||
"es": "Ver actualización del microagente",
|
||||
"ar": "عرض تحديث الوكيل المصغر",
|
||||
"fr": "Voir la mise à jour du micro-agent",
|
||||
"tr": "Mikro ajan güncellemesini görüntüle",
|
||||
"de": "Microagent-Update anzeigen",
|
||||
"uk": "Переглянути оновлення мікроагента"
|
||||
},
|
||||
"MICROAGENT$STATUS_ERROR": {
|
||||
"en": "Microagent encountered an error",
|
||||
"ja": "マイクロエージェントでエラーが発生しました",
|
||||
"zh-CN": "微代理遇到错误",
|
||||
"zh-TW": "微代理遇到錯誤",
|
||||
"ko-KR": "마이크로에이전트에서 오류가 발생했습니다",
|
||||
"no": "Mikroagent støtte på en feil",
|
||||
"it": "Il microagente ha riscontrato un errore",
|
||||
"pt": "Microagente encontrou um erro",
|
||||
"es": "El microagente encontró un error",
|
||||
"ar": "واجه الوكيل المصغر خطأ",
|
||||
"fr": "Le micro-agent a rencontré une erreur",
|
||||
"tr": "Mikro ajan bir hatayla karşılaştı",
|
||||
"de": "Microagent ist auf einen Fehler gestoßen",
|
||||
"uk": "Мікроагент зіткнувся з помилкою"
|
||||
},
|
||||
"MICROAGENT$VIEW_YOUR_PR": {
|
||||
"en": "View your PR",
|
||||
"ja": "PRを表示",
|
||||
"zh-CN": "查看您的PR",
|
||||
"zh-TW": "查看您的PR",
|
||||
"ko-KR": "PR 보기",
|
||||
"no": "Se din PR",
|
||||
"it": "Visualizza la tua PR",
|
||||
"pt": "Ver seu PR",
|
||||
"es": "Ver tu PR",
|
||||
"ar": "عرض طلب السحب الخاص بك",
|
||||
"fr": "Voir votre PR",
|
||||
"tr": "PR'ınızı görüntüleyin",
|
||||
"de": "Ihre PR anzeigen",
|
||||
"uk": "Переглянути ваш PR"
|
||||
},
|
||||
"MICROAGENT$DESCRIBE_WHAT_TO_ADD": {
|
||||
"en": "Describe what you want to add to the Microagent...",
|
||||
"ja": "マイクロエージェントに追加したい内容を説明してください...",
|
||||
"zh-CN": "描述您想添加到微代理的内容...",
|
||||
"zh-TW": "描述您想添加到微代理的內容...",
|
||||
"ko-KR": "마이크로에이전트에 추가하고 싶은 내용을 설명하세요...",
|
||||
"no": "Beskriv hva du vil legge til i mikroagenten...",
|
||||
"it": "Descrivi cosa vuoi aggiungere al microagente...",
|
||||
"pt": "Descreva o que você deseja adicionar ao microagente...",
|
||||
"es": "Describe lo que quieres añadir al microagente...",
|
||||
"ar": "صف ما تريد إضافته إلى الوكيل المصغر...",
|
||||
"fr": "Décrivez ce que vous souhaitez ajouter au micro-agent...",
|
||||
"tr": "Mikro ajana eklemek istediğinizi açıklayın...",
|
||||
"de": "Beschreiben Sie, was Sie zum Microagent hinzufügen möchten...",
|
||||
"uk": "Опишіть, що ви хочете додати до мікроагента..."
|
||||
},
|
||||
"MICROAGENT$SELECT_FILE_OR_CUSTOM": {
|
||||
"en": "Select a microagent file or enter a custom value",
|
||||
"ja": "マイクロエージェントファイルを選択するか、カスタム値を入力してください",
|
||||
"zh-CN": "选择微代理文件或输入自定义值",
|
||||
"zh-TW": "選擇微代理文件或輸入自定義值",
|
||||
"ko-KR": "마이크로에이전트 파일을 선택하거나 사용자 지정 값을 입력하세요",
|
||||
"no": "Velg en mikroagent-fil eller skriv inn en egendefinert verdi",
|
||||
"it": "Seleziona un file microagente o inserisci un valore personalizzato",
|
||||
"pt": "Selecione um arquivo de microagente ou insira um valor personalizado",
|
||||
"es": "Selecciona un archivo de microagente o introduce un valor personalizado",
|
||||
"ar": "حدد ملف وكيل مصغر أو أدخل قيمة مخصصة",
|
||||
"fr": "Sélectionnez un fichier micro-agent ou entrez une valeur personnalisée",
|
||||
"tr": "Bir mikro ajan dosyası seçin veya özel bir değer girin",
|
||||
"de": "Wählen Sie eine Microagent-Datei aus oder geben Sie einen benutzerdefinierten Wert ein",
|
||||
"uk": "Виберіть файл мікроагента або введіть власне значення"
|
||||
},
|
||||
"MICROAGENT$TYPE_TRIGGER_SPACE": {
|
||||
"en": "Type a trigger and press Space to add it",
|
||||
"ja": "トリガーを入力し、スペースキーを押して追加してください",
|
||||
"zh-CN": "输入触发器并按空格键添加",
|
||||
"zh-TW": "輸入觸發器並按空格鍵添加",
|
||||
"ko-KR": "트리거를 입력하고 스페이스바를 눌러 추가하세요",
|
||||
"no": "Skriv inn en utløser og trykk mellomrom for å legge den til",
|
||||
"it": "Digita un trigger e premi Spazio per aggiungerlo",
|
||||
"pt": "Digite um gatilho e pressione Espaço para adicioná-lo",
|
||||
"es": "Escribe un disparador y pulsa Espacio para añadirlo",
|
||||
"ar": "اكتب مشغلًا واضغط على المسافة لإضافته",
|
||||
"fr": "Tapez un déclencheur et appuyez sur Espace pour l'ajouter",
|
||||
"tr": "Bir tetikleyici yazın ve eklemek için Boşluk tuşuna basın",
|
||||
"de": "Geben Sie einen Auslöser ein und drücken Sie die Leertaste, um ihn hinzuzufügen",
|
||||
"uk": "Введіть тригер і натисніть пробіл, щоб додати його"
|
||||
},
|
||||
"MICROAGENT$LOADING_PROMPT": {
|
||||
"en": "Loading prompt...",
|
||||
"ja": "プロンプトを読み込み中...",
|
||||
"zh-CN": "加载提示中...",
|
||||
"zh-TW": "加載提示中...",
|
||||
"ko-KR": "프롬프트 로딩 중...",
|
||||
"no": "Laster inn prompt...",
|
||||
"it": "Caricamento prompt...",
|
||||
"pt": "Carregando prompt...",
|
||||
"es": "Cargando prompt...",
|
||||
"ar": "جاري تحميل المطالبة...",
|
||||
"fr": "Chargement du prompt...",
|
||||
"tr": "İstem yükleniyor...",
|
||||
"de": "Prompt wird geladen...",
|
||||
"uk": "Завантаження підказки..."
|
||||
},
|
||||
"MICROAGENT$CANCEL": {
|
||||
"en": "Cancel",
|
||||
"ja": "キャンセル",
|
||||
"zh-CN": "取消",
|
||||
"zh-TW": "取消",
|
||||
"ko-KR": "취소",
|
||||
"no": "Avbryt",
|
||||
"it": "Annulla",
|
||||
"pt": "Cancelar",
|
||||
"es": "Cancelar",
|
||||
"ar": "إلغاء",
|
||||
"fr": "Annuler",
|
||||
"tr": "İptal",
|
||||
"de": "Abbrechen",
|
||||
"uk": "Скасувати"
|
||||
},
|
||||
"MICROAGENT$LAUNCH": {
|
||||
"en": "Launch",
|
||||
"ja": "起動",
|
||||
"zh-CN": "启动",
|
||||
"zh-TW": "啟動",
|
||||
"ko-KR": "시작",
|
||||
"no": "Start",
|
||||
"it": "Avvia",
|
||||
"pt": "Iniciar",
|
||||
"es": "Iniciar",
|
||||
"ar": "إطلاق",
|
||||
"fr": "Lancer",
|
||||
"tr": "Başlat",
|
||||
"de": "Starten",
|
||||
"uk": "Запустити"
|
||||
},
|
||||
"STATUS$WEBSOCKET_CLOSED": {
|
||||
"en": "The WebSocket connection was closed.",
|
||||
"ja": "WebSocket接続が閉じられました。",
|
||||
|
||||
24
frontend/src/icons/memory_icon.svg
Normal file
24
frontend/src/icons/memory_icon.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 22.3 18.66">
|
||||
<!-- Generator: Adobe Illustrator 29.5.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 141) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
stroke-miterlimit: 10;
|
||||
}
|
||||
|
||||
.st0, .st1 {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="st1" d="M15.15,12.54h3.26c1.58,0,2.93-1.29,2.9-2.88-.03-1.53-1.28-2.77-2.82-2.77-.04,0-.08,0-.11,0,.13-.44.16-.92.04-1.43-.27-1.17-1.27-2.05-2.46-2.17-.74-.07-1.43.14-1.97.55,0,0,0-.02,0-.03,0-1.56-1.26-2.82-2.82-2.82s-2.82,1.26-2.82,2.82c0,0,0,.02,0,.03-.54-.4-1.23-.62-1.97-.55-1.19.12-2.19,1-2.46,2.17-.12.5-.09.99.04,1.43-.04,0-.08,0-.11,0-1.56,0-2.82,1.26-2.82,2.82s1.26,2.82,2.82,2.82l1.29.03c.41,0,.74.34.74.75v1.85c0,1.38,1.12,2.5,2.5,2.5h.29c1.44,0,2.6-1.17,2.6-2.6V6.49"/>
|
||||
<polyline class="st0" points="7.97 9.74 11.22 6.49 14.47 9.74"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@ -37,6 +37,7 @@ import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { TabContent } from "#/components/layout/tab-content";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
|
||||
function AppContent() {
|
||||
@ -195,23 +196,25 @@ function AppContent() {
|
||||
|
||||
return (
|
||||
<WsClientProvider conversationId={conversationId}>
|
||||
<EventHandler>
|
||||
<div data-testid="app-route" className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto">{renderMain()}</div>
|
||||
<ConversationSubscriptionsProvider>
|
||||
<EventHandler>
|
||||
<div data-testid="app-route" className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto">{renderMain()}</div>
|
||||
|
||||
<Controls
|
||||
setSecurityOpen={onSecurityModalOpen}
|
||||
showSecurityLock={!!settings?.SECURITY_ANALYZER}
|
||||
/>
|
||||
{settings && (
|
||||
<Security
|
||||
isOpen={securityModalIsOpen}
|
||||
onOpenChange={onSecurityModalOpenChange}
|
||||
securityAnalyzer={settings.SECURITY_ANALYZER}
|
||||
<Controls
|
||||
setSecurityOpen={onSecurityModalOpen}
|
||||
showSecurityLock={!!settings?.SECURITY_ANALYZER}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EventHandler>
|
||||
{settings && (
|
||||
<Security
|
||||
isOpen={securityModalIsOpen}
|
||||
onOpenChange={onSecurityModalOpenChange}
|
||||
securityAnalyzer={settings.SECURITY_ANALYZER}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EventHandler>
|
||||
</ConversationSubscriptionsProvider>
|
||||
</WsClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
OpenHandsAction,
|
||||
SystemMessageAction,
|
||||
CommandAction,
|
||||
FinishAction,
|
||||
} from "./actions";
|
||||
import {
|
||||
AgentStateChangeObservation,
|
||||
@ -15,6 +16,16 @@ import {
|
||||
} from "./observations";
|
||||
import { StatusUpdate } from "./variances";
|
||||
|
||||
export const isOpenHandsEvent = (
|
||||
event: unknown,
|
||||
): event is OpenHandsParsedEvent =>
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"id" in event &&
|
||||
"source" in event &&
|
||||
"message" in event &&
|
||||
"timestamp" in event;
|
||||
|
||||
export const isOpenHandsAction = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is OpenHandsAction => "action" in event;
|
||||
@ -58,7 +69,7 @@ export const isCommandObservation = (
|
||||
|
||||
export const isFinishAction = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is AssistantMessageAction =>
|
||||
): event is FinishAction =>
|
||||
isOpenHandsAction(event) && event.action === "finish";
|
||||
|
||||
export const isSystemMessage = (
|
||||
@ -76,7 +87,9 @@ export const isMcpObservation = (
|
||||
): event is MCPObservation =>
|
||||
isOpenHandsObservation(event) && event.observation === "mcp";
|
||||
|
||||
export const isStatusUpdate = (
|
||||
event: OpenHandsParsedEvent,
|
||||
): event is StatusUpdate =>
|
||||
"status_update" in event && "type" in event && "id" in event;
|
||||
export const isStatusUpdate = (event: unknown): event is StatusUpdate =>
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"status_update" in event &&
|
||||
"type" in event &&
|
||||
"id" in event;
|
||||
|
||||
@ -35,7 +35,7 @@ interface LocalUserMessageAction {
|
||||
|
||||
export interface StatusUpdate {
|
||||
status_update: true;
|
||||
type: "error";
|
||||
type: "error" | "info";
|
||||
id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
12
frontend/src/types/microagent-status.ts
Normal file
12
frontend/src/types/microagent-status.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export enum MicroagentStatus {
|
||||
CREATING = "creating",
|
||||
COMPLETED = "completed",
|
||||
ERROR = "error",
|
||||
}
|
||||
|
||||
export interface EventMicroagentStatus {
|
||||
eventId: number;
|
||||
conversationId: string;
|
||||
status: MicroagentStatus;
|
||||
prUrl?: string; // Optional PR URL for completed status
|
||||
}
|
||||
@ -9,7 +9,7 @@ const TOAST_STYLE: CSSProperties = {
|
||||
borderRadius: "4px",
|
||||
};
|
||||
|
||||
const TOAST_OPTIONS: ToastOptions = {
|
||||
export const TOAST_OPTIONS: ToastOptions = {
|
||||
position: "top-right",
|
||||
style: TOAST_STYLE,
|
||||
};
|
||||
|
||||
57
frontend/src/utils/parse-pr-url.ts
Normal file
57
frontend/src/utils/parse-pr-url.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Utility function to parse Pull Request URLs from text
|
||||
*/
|
||||
|
||||
// Common PR URL patterns for different Git providers
|
||||
const PR_URL_PATTERNS = [
|
||||
// GitHub: https://github.com/owner/repo/pull/123
|
||||
/https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/gi,
|
||||
// GitLab: https://gitlab.com/owner/repo/-/merge_requests/123
|
||||
/https?:\/\/gitlab\.com\/[^/\s]+\/[^/\s]+\/-\/merge_requests\/\d+/gi,
|
||||
// GitLab self-hosted: https://gitlab.example.com/owner/repo/-/merge_requests/123
|
||||
/https?:\/\/[^/\s]*gitlab[^/\s]*\/[^/\s]+\/[^/\s]+\/-\/merge_requests\/\d+/gi,
|
||||
// Bitbucket: https://bitbucket.org/owner/repo/pull-requests/123
|
||||
/https?:\/\/bitbucket\.org\/[^/\s]+\/[^/\s]+\/pull-requests\/\d+/gi,
|
||||
// Azure DevOps: https://dev.azure.com/org/project/_git/repo/pullrequest/123
|
||||
/https?:\/\/dev\.azure\.com\/[^/\s]+\/[^/\s]+\/_git\/[^/\s]+\/pullrequest\/\d+/gi,
|
||||
// Generic pattern for other providers that might use /pull/ or /pr/
|
||||
/https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/(?:pull|pr)\/\d+/gi,
|
||||
];
|
||||
|
||||
/**
|
||||
* Extracts PR URLs from a given text
|
||||
* @param text - The text to search for PR URLs
|
||||
* @returns Array of found PR URLs
|
||||
*/
|
||||
export function extractPRUrls(text: string): string[] {
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const pattern of PR_URL_PATTERNS) {
|
||||
const matches = text.match(pattern);
|
||||
if (matches) {
|
||||
urls.push(...matches);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and return
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the text contains any PR URLs
|
||||
* @param text - The text to check
|
||||
* @returns True if PR URLs are found, false otherwise
|
||||
*/
|
||||
export function containsPRUrl(text: string): boolean {
|
||||
return extractPRUrls(text).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the first PR URL found in the text
|
||||
* @param text - The text to search
|
||||
* @returns The first PR URL found, or null if none found
|
||||
*/
|
||||
export function getFirstPRUrl(text: string): string | null {
|
||||
const urls = extractPRUrls(text);
|
||||
return urls.length > 0 ? urls[0] : null;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user