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:
sp.wack 2025-07-08 20:24:07 +04:00 committed by GitHub
parent a6ffb2f799
commit 794eedf503
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 2472 additions and 365 deletions

View File

@ -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>;

View File

@ -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();
});
});

View 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();
});
});

View File

@ -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,

View File

@ -206,9 +206,8 @@ describe("RepoConnector", () => {
"rbren/polaris",
"github",
undefined,
[],
undefined,
undefined,
"main",
undefined,
);
});

View File

@ -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} />, {

View File

@ -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,
);
});

View File

@ -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();
});
});

View 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",
);
});
});

View 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");
});
});
});

View File

@ -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);
});
});
});

View File

@ -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",

View 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;
}
}

View File

@ -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>(

View File

@ -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;

View File

@ -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>

View File

@ -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)) {

View File

@ -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,
)}
</>
);
},

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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",
)}
/>
);
}

View File

@ -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>
);
}

View File

@ -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,
},
);

View File

@ -409,10 +409,7 @@ export function ConversationCard({
/>
{microagentsModalVisible && (
<MicroagentsModal
onClose={() => setMicroagentsModalVisible(false)}
conversationId={conversationId}
/>
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
)}
</>
);

View File

@ -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) => ({

View File

@ -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")}

View File

@ -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();
});
});

View File

@ -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"}

View File

@ -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,
});
};

View File

@ -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]",

View 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>
);
}

View File

@ -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} />}

View 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>
);
}

View 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);
}

View File

@ -328,6 +328,7 @@ export function WsClientProvider({
transports: ["websocket"],
query,
});
sio.on("connect", handleConnect);
sio.on("oh_event", handleMessage);
sio.on("connect_error", handleError);

View File

@ -67,6 +67,7 @@ prepareApp().then(() =>
<QueryClientProvider client={queryClient}>
<HydratedRouter />
<PosthogInit />
<div id="modal-portal-exit" />
</QueryClientProvider>
</Provider>
</StrictMode>,

View File

@ -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}`);
},
});
};

View File

@ -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
});
};

View File

@ -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),
});
};

View 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, "")),
});
};

View 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
});
};

View File

@ -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,
};
};

View File

@ -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",

View File

@ -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接続が閉じられました。",

View 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

View File

@ -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>
);
}

View File

@ -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;

View File

@ -35,7 +35,7 @@ interface LocalUserMessageAction {
export interface StatusUpdate {
status_update: true;
type: "error";
type: "error" | "info";
id: string;
message: string;
}

View 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
}

View File

@ -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,
};

View 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;
}