mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 15:03:43 +08:00
chore: remove unreachable frontend modules and their tests (#16606)
This commit is contained in:
@@ -1,54 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import { getCloudSuggestedTasks } from "#/api/cloud/suggestions-service.api";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(
|
||||
mockJsonResponse({ items: [], next_page_id: null }),
|
||||
);
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("getCloudSuggestedTasks", () => {
|
||||
it("forwards limit and pageId to the upstream /api/v1/git/suggested-tasks/search endpoint", async () => {
|
||||
// Act
|
||||
await getCloudSuggestedTasks({ limit: 10, pageId: "p2" });
|
||||
|
||||
// Assert
|
||||
const [url, init] = getFetchCall(fetchMock);
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect(url).toContain("/api/v1/git/suggested-tasks/search");
|
||||
expect(url).toContain("limit=10");
|
||||
expect(url).toContain("page_id=p2");
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import * as cloudSuggestions from "#/api/cloud/suggestions-service.api";
|
||||
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import type { SuggestedTask } from "#/utils/types";
|
||||
|
||||
vi.mock("#/api/cloud/suggestions-service.api", () => ({
|
||||
getCloudSuggestedTasks: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetCloudSuggestedTasks = vi.mocked(
|
||||
cloudSuggestions.getCloudSuggestedTasks,
|
||||
);
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
host: "http://localhost",
|
||||
apiKey: "local-key",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
mockGetCloudSuggestedTasks.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("SuggestionsService.getSuggestedTasks", () => {
|
||||
it("returns the cloud endpoint items when the active backend is cloud", async () => {
|
||||
// Arrange
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
const items: SuggestedTask[] = [
|
||||
{
|
||||
issue_number: 1,
|
||||
title: "Fix CI",
|
||||
repo: "octo/foo",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
mockGetCloudSuggestedTasks.mockResolvedValueOnce({
|
||||
items,
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await SuggestionsService.getSuggestedTasks();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(items);
|
||||
});
|
||||
|
||||
it("returns an empty list without calling the cloud endpoint when the active backend is local", async () => {
|
||||
// Arrange
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
|
||||
// Act
|
||||
const result = await SuggestionsService.getSuggestedTasks();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([]);
|
||||
expect(mockGetCloudSuggestedTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { MetadataChip } from "#/components/features/automations/metadata-chip";
|
||||
|
||||
describe("MetadataChip", () => {
|
||||
it("renders the label and icon", () => {
|
||||
render(
|
||||
<MetadataChip
|
||||
icon={<span data-testid="chip-icon" />}
|
||||
label="acme/frontend-app"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("acme/frontend-app")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chip-icon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
-231
@@ -1,231 +0,0 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
||||
import { NewConversationButton } from "#/components/features/conversation-panel/new-conversation-button";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
const mockUseUserProviders = vi.fn();
|
||||
const mockUseActiveBackend = vi.fn();
|
||||
const mockUseGitRepositories = vi.fn();
|
||||
const mockUseSearchRepositories = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
}));
|
||||
|
||||
vi.mock("#/contexts/active-backend-context", () => ({
|
||||
useActiveBackend: () => mockUseActiveBackend(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-git-repositories", () => ({
|
||||
useGitRepositories: (...args: unknown[]) => mockUseGitRepositories(...args),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-search-repositories", () => ({
|
||||
useSearchRepositories: (...args: unknown[]) =>
|
||||
mockUseSearchRepositories(...args),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackConversationCreated: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const makeRepo = (
|
||||
full_name: string,
|
||||
overrides: Partial<GitRepository> = {},
|
||||
): GitRepository => ({
|
||||
id: full_name,
|
||||
full_name,
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeStartTask = (conversationId: string) => ({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY" as const,
|
||||
detail: null,
|
||||
app_conversation_id: conversationId,
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: "github" as const,
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default" as const,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
describe("NewConversationButton (cloud)", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
mockUseActiveBackend.mockReturnValue({
|
||||
backend: { id: "cloud-1", kind: "cloud" },
|
||||
orgId: null,
|
||||
});
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: ["github"],
|
||||
isLoadingSettings: false,
|
||||
});
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
items: [
|
||||
makeRepo("octo/cat", { main_branch: "main" }),
|
||||
makeRepo("octo/dog", { main_branch: "trunk" }),
|
||||
],
|
||||
next_page_id: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
mockUseSearchRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders a list of repositories from the active provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NewConversationButton />);
|
||||
|
||||
await user.click(screen.getByTestId("new-conversation-button"));
|
||||
|
||||
expect(screen.getByTestId("new-conversation-popover")).toBeInTheDocument();
|
||||
const items = screen.getAllByTestId("launch-repository");
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toHaveAttribute("data-repo-name", "octo/cat");
|
||||
expect(items[1]).toHaveAttribute("data-repo-name", "octo/dog");
|
||||
|
||||
// No workspace footer in cloud mode.
|
||||
expect(
|
||||
screen.queryByTestId("add-workspaces-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("launch-no-workspace")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("launches a conversation against the repo's default branch", async () => {
|
||||
const navigate = vi.fn();
|
||||
const createSpy = vi
|
||||
.spyOn(AgentServerConversationService, "createConversation")
|
||||
.mockResolvedValue(makeStartTask("conv-xyz"));
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NewConversationButton />, {
|
||||
navigation: { navigate, currentPath: "/conversations" },
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("new-conversation-button"));
|
||||
const items = screen.getAllByTestId("launch-repository");
|
||||
await user.click(items[1]); // octo/dog, main_branch = "trunk"
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSpy).toHaveBeenCalledWith({
|
||||
metadata: {
|
||||
selected_repository: "octo/dog",
|
||||
selected_branch: "trunk",
|
||||
git_provider: "github",
|
||||
},
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(navigate).toHaveBeenCalledWith("/conversations/conv-xyz");
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to "main" when the repository has no main_branch metadata', async () => {
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: {
|
||||
pages: [{ items: [makeRepo("octo/no-main")], next_page_id: null }],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
const createSpy = vi
|
||||
.spyOn(AgentServerConversationService, "createConversation")
|
||||
.mockResolvedValue(makeStartTask("conv-abc"));
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NewConversationButton />);
|
||||
|
||||
await user.click(screen.getByTestId("new-conversation-button"));
|
||||
await user.click(screen.getByTestId("launch-repository"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSpy).toHaveBeenCalledWith({
|
||||
metadata: {
|
||||
selected_repository: "octo/no-main",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("renders provider tabs when multiple providers are connected", async () => {
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: ["github", "gitlab"],
|
||||
isLoadingSettings: false,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NewConversationButton />);
|
||||
|
||||
await user.click(screen.getByTestId("new-conversation-button"));
|
||||
|
||||
expect(screen.getByTestId("cloud-provider-tabs")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cloud-provider-tab-github")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cloud-provider-tab-gitlab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when no repositories are returned", async () => {
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [{ items: [], next_page_id: null }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NewConversationButton />);
|
||||
|
||||
await user.click(screen.getByTestId("new-conversation-button"));
|
||||
|
||||
expect(screen.getByTestId("cloud-repo-empty")).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId("launch-repository")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HomeHeader } from "#/components/features/home/home-header/home-header";
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
// Return a mock translation for the test
|
||||
const translations: Record<string, string> = {
|
||||
HOME$LETS_START_BUILDING: "Let's start building",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderHomeHeader = () => {
|
||||
return render(<HomeHeader />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
describe("HomeHeader", () => {
|
||||
it("should render the header with the correct title", () => {
|
||||
renderHomeHeader();
|
||||
|
||||
const title = screen.getByText("Let's start building");
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the GuideMessage component", () => {
|
||||
renderHomeHeader();
|
||||
|
||||
// The GuideMessage component should be rendered as part of the header
|
||||
const header = screen.getByRole("banner");
|
||||
expect(header).toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
||||
import { NewConversation } from "#/components/features/home/new-conversation/new-conversation";
|
||||
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>(
|
||||
"#/hooks/query/use-settings",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getSettingsQueryFn: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
// Return a mock translation for the test
|
||||
const translations: Record<string, string> = {
|
||||
COMMON$START_FROM_SCRATCH: "Start from Scratch",
|
||||
HOME$NEW_PROJECT_DESCRIPTION: "Create a new project from scratch",
|
||||
COMMON$NEW_CONVERSATION: "New Conversation",
|
||||
HOME$LOADING: "Loading...",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderNewConversation = (navigate = vi.fn()) =>
|
||||
renderWithProviders(<NewConversation />, {
|
||||
navigation: { navigate },
|
||||
});
|
||||
|
||||
describe("NewConversation", () => {
|
||||
it("should create an empty conversation and navigate when pressing the launch from scratch button", async () => {
|
||||
const navigate = vi.fn();
|
||||
const createConversationSpy = vi
|
||||
.spyOn(AgentServerConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "conv-123",
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
renderNewConversation(navigate);
|
||||
|
||||
const launchButton = screen.getByTestId("launch-new-conversation-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledOnce();
|
||||
await waitFor(() => {
|
||||
expect(navigate).toHaveBeenCalledWith("/conversations/conv-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
|
||||
// Mock V1 API to never resolve, keeping the mutation in loading state
|
||||
vi.spyOn(AgentServerConversationService, "createConversation").mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
|
||||
renderNewConversation();
|
||||
|
||||
const launchButton = screen.getByTestId("launch-new-conversation-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(launchButton).toHaveTextContent(/Loading.../i);
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import { TaskCard } from "#/components/features/home/tasks/task-card";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("#/hooks/query/use-settings")
|
||||
>("#/hooks/query/use-settings");
|
||||
return {
|
||||
...actual,
|
||||
getSettingsQueryFn: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
|
||||
const MOCK_TASK_1: SuggestedTask = {
|
||||
issue_number: 123,
|
||||
repo: "repo1",
|
||||
title: "Task 1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
};
|
||||
|
||||
const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
{ id: "1", full_name: "repo1", git_provider: "github", is_public: true },
|
||||
{ id: "2", full_name: "repo2", git_provider: "github", is_public: true },
|
||||
{ id: "3", full_name: "repo3", git_provider: "gitlab", is_public: true },
|
||||
{ id: "4", full_name: "repo4", git_provider: "gitlab", is_public: true },
|
||||
{
|
||||
id: "5",
|
||||
full_name: "repo5",
|
||||
git_provider: "azure_devops",
|
||||
is_public: true,
|
||||
},
|
||||
];
|
||||
|
||||
const renderTaskCard = (task = MOCK_TASK_1, navigate = vi.fn()) =>
|
||||
renderWithProviders(<TaskCard task={task} />, {
|
||||
navigation: { navigate },
|
||||
});
|
||||
|
||||
describe("TaskCard", () => {
|
||||
it("format the issue id", async () => {
|
||||
renderTaskCard();
|
||||
|
||||
const taskId = screen.getByTestId("task-id");
|
||||
expect(taskId).toHaveTextContent(/#123/i);
|
||||
});
|
||||
|
||||
it("should call createConversation when clicking the launch button", async () => {
|
||||
const createConversationSpy = vi
|
||||
.spyOn(AgentServerConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "conv-123",
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
renderTaskCard();
|
||||
|
||||
const launchButton = screen.getByTestId("task-launch-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createConversationSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("creating suggested task conversation", () => {
|
||||
beforeEach(() => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
items: MOCK_RESPOSITORIES,
|
||||
next_page_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
|
||||
const createConversationSpy = vi
|
||||
.spyOn(AgentServerConversationService, "createConversation")
|
||||
.mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "conv-123",
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: MOCK_RESPOSITORIES[0].full_name,
|
||||
selected_branch: null,
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
renderTaskCard(MOCK_TASK_1);
|
||||
|
||||
const launchButton = screen.getByTestId("task-launch-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith({
|
||||
metadata: {
|
||||
selected_repository: MOCK_TASK_1.repo,
|
||||
selected_branch: null,
|
||||
git_provider: MOCK_TASK_1.git_provider,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate to the conversation page after creating a conversation", async () => {
|
||||
const navigate = vi.fn();
|
||||
|
||||
vi.spyOn(
|
||||
AgentServerConversationService,
|
||||
"createConversation",
|
||||
).mockResolvedValue({
|
||||
id: "task-id",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "test-conversation-id",
|
||||
agent_server_url: "http://agent-server.local",
|
||||
request: {
|
||||
initial_message: null,
|
||||
processors: [],
|
||||
llm_model: null,
|
||||
selected_repository: "repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
suggested_task: null,
|
||||
title: null,
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
parent_conversation_id: null,
|
||||
agent_type: "default",
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
renderTaskCard(MOCK_TASK_1, navigate);
|
||||
|
||||
const launchButton = screen.getByTestId("task-launch-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
"/conversations/test-conversation-id",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
|
||||
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
|
||||
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...(actual as object),
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the dependencies for useShouldShowUserFeatures
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => ({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({
|
||||
data: {},
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { useUserProvidersMock } = vi.hoisted(() => ({
|
||||
useUserProvidersMock: vi.fn(() => ({
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
isLoading: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => useUserProvidersMock(),
|
||||
}));
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "k",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const renderTaskSuggestions = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => <TaskSuggestions />,
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="conversation-screen" />,
|
||||
path: "/conversations/:conversationId",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="settings-screen" />,
|
||||
path: "/settings",
|
||||
},
|
||||
]);
|
||||
|
||||
return render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ActiveBackendProvider>{children}</ActiveBackendProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
describe("TaskSuggestions", () => {
|
||||
const getSuggestedTasksSpy = vi.spyOn(
|
||||
SuggestionsService,
|
||||
"getSuggestedTasks",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
it("should render the task suggestions section", () => {
|
||||
renderTaskSuggestions();
|
||||
screen.getByTestId("task-suggestions");
|
||||
});
|
||||
|
||||
it("renders nothing when the active backend is local", () => {
|
||||
setRegisteredBackends([]);
|
||||
setActiveSelection(null);
|
||||
|
||||
renderTaskSuggestions();
|
||||
|
||||
expect(screen.queryByTestId("task-suggestions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render an empty message if there are no tasks", async () => {
|
||||
getSuggestedTasksSpy.mockResolvedValue([]);
|
||||
renderTaskSuggestions();
|
||||
await screen.findByText("TASKS$NO_TASKS_AVAILABLE");
|
||||
});
|
||||
|
||||
it("should render the task groups with the correct titles", async () => {
|
||||
getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
|
||||
renderTaskSuggestions();
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for repository names (grouped by repo) - only the first 3 tasks are shown
|
||||
screen.getByText("octocat/hello-world");
|
||||
screen.getByText("octocat/earth");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the task cards with the correct task details", async () => {
|
||||
getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
|
||||
renderTaskSuggestions();
|
||||
|
||||
await waitFor(() => {
|
||||
// Only check for the first 3 tasks that are actually rendered
|
||||
// The component limits to 3 tasks due to getLimitedTaskGroups function
|
||||
screen.getByText("Fix merge conflicts"); // First task from octocat/hello-world
|
||||
screen.getByText("Fix broken CI checks"); // First task from octocat/earth
|
||||
screen.getByText("Fix issue"); // Second task from octocat/earth
|
||||
});
|
||||
});
|
||||
|
||||
it("should render skeletons when loading", async () => {
|
||||
getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
|
||||
renderTaskSuggestions();
|
||||
|
||||
const skeletons = await screen.findAllByTestId("task-group-skeleton");
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for repository names (grouped by repo) - only the first 3 tasks are shown
|
||||
screen.getByText("octocat/hello-world");
|
||||
screen.getByText("octocat/earth");
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { MCPServerList } from "#/components/features/settings/mcp-settings/mcp-server-list";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockServers = [
|
||||
{
|
||||
id: "sse-0",
|
||||
type: "sse" as const,
|
||||
url: "https://very-long-url-that-could-cause-layout-overflow.example.com/api/v1/mcp/server/endpoint/with/many/path/segments",
|
||||
},
|
||||
{
|
||||
id: "stdio-0",
|
||||
type: "stdio" as const,
|
||||
name: "test-stdio-server",
|
||||
command: "python",
|
||||
args: ["-m", "test_server"],
|
||||
},
|
||||
];
|
||||
|
||||
describe("MCPServerList", () => {
|
||||
it("should render servers with proper layout structure", () => {
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<MCPServerList
|
||||
servers={mockServers}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that the table structure is rendered
|
||||
const table = screen.getByRole("table");
|
||||
expect(table).toBeInTheDocument();
|
||||
|
||||
// Check that server items are rendered
|
||||
const serverItems = screen.getAllByTestId("mcp-server-item");
|
||||
expect(serverItems).toHaveLength(2);
|
||||
|
||||
// Check that action buttons are present for each server
|
||||
const editButtons = screen.getAllByTestId("edit-mcp-server-button");
|
||||
const deleteButtons = screen.getAllByTestId("delete-mcp-server-button");
|
||||
expect(editButtons).toHaveLength(2);
|
||||
expect(deleteButtons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should render empty state when no servers", () => {
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<MCPServerList
|
||||
servers={[]}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("SETTINGS$MCP_NO_SERVERS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle long URLs without breaking layout", () => {
|
||||
const longUrlServer = {
|
||||
id: "sse-0",
|
||||
type: "sse" as const,
|
||||
url: "https://extremely-long-url-that-would-previously-cause-layout-overflow-and-push-action-buttons-out-of-view.example.com/api/v1/mcp/server/endpoint/with/many/path/segments/and/query/parameters?param1=value1¶m2=value2¶m3=value3",
|
||||
};
|
||||
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<MCPServerList
|
||||
servers={[longUrlServer]}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that action buttons are still present and accessible
|
||||
const editButton = screen.getByTestId("edit-mcp-server-button");
|
||||
const deleteButton = screen.getByTestId("delete-mcp-server-button");
|
||||
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
// Check that the URL is properly displayed with title attribute for accessibility
|
||||
const detailsCells = screen.getAllByTitle(longUrlServer.url);
|
||||
expect(detailsCells).toHaveLength(2); // Name and Details columns both have the URL
|
||||
});
|
||||
|
||||
it("should display command and arguments for STDIO servers", () => {
|
||||
const stdioServer = {
|
||||
id: "stdio-1",
|
||||
type: "stdio" as const,
|
||||
name: "test-server",
|
||||
command: "python",
|
||||
args: ["-m", "test_module", "--verbose"],
|
||||
};
|
||||
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<MCPServerList
|
||||
servers={[stdioServer]}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that the server details show command + arguments
|
||||
const expectedDetails = "python -m test_module --verbose";
|
||||
expect(screen.getByTitle(expectedDetails)).toBeInTheDocument();
|
||||
expect(screen.getByText(expectedDetails)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fallback to server name for STDIO servers without command", () => {
|
||||
const stdioServer = {
|
||||
id: "stdio-2",
|
||||
type: "stdio" as const,
|
||||
name: "fallback-server",
|
||||
};
|
||||
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<MCPServerList
|
||||
servers={[stdioServer]}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that the server details show the server name as fallback
|
||||
// Both name and details columns will have the same value, so we expect 2 elements
|
||||
const fallbackElements = screen.getAllByTitle("fallback-server");
|
||||
expect(fallbackElements).toHaveLength(2);
|
||||
|
||||
const fallbackTextElements = screen.getAllByText("fallback-server");
|
||||
expect(fallbackTextElements).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { handleStatusMessage } from "#/services/actions";
|
||||
import { StatusMessage } from "#/types/message";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { useStatusStore } from "#/stores/status-store";
|
||||
import { trackError } from "#/utils/error-handler";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: {
|
||||
invalidateQueries: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/stores/status-store", () => ({
|
||||
useStatusStore: {
|
||||
getState: vi.fn(() => ({
|
||||
setCurStatusMessage: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/error-handler", () => ({
|
||||
trackError: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("handleStatusMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should invalidate queries when receiving a conversation title update", () => {
|
||||
// Create a status message with a conversation title
|
||||
const statusMessage: StatusMessage = {
|
||||
status_update: true,
|
||||
type: "info",
|
||||
message: "conversation-123",
|
||||
conversation_title: "New Conversation Title",
|
||||
};
|
||||
|
||||
// Call the function
|
||||
handleStatusMessage(statusMessage);
|
||||
|
||||
// Verify that queryClient.invalidateQueries was called with the correct parameters
|
||||
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["user", "conversation", "conversation-123"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should call setCurStatusMessage for info messages without conversation_title", () => {
|
||||
// Create a status message without a conversation title
|
||||
const statusMessage: StatusMessage = {
|
||||
status_update: true,
|
||||
type: "info",
|
||||
message: "Some info message",
|
||||
};
|
||||
|
||||
const mockSetCurStatusMessage = vi.fn();
|
||||
vi.mocked(useStatusStore.getState).mockReturnValue({
|
||||
setCurStatusMessage: mockSetCurStatusMessage,
|
||||
curStatusMessage: {
|
||||
status_update: true,
|
||||
type: "info",
|
||||
id: "",
|
||||
message: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Call the function
|
||||
handleStatusMessage(statusMessage);
|
||||
|
||||
// Verify that setCurStatusMessage was called with the correct message
|
||||
expect(mockSetCurStatusMessage).toHaveBeenCalledWith(statusMessage);
|
||||
|
||||
// Verify that queryClient.invalidateQueries was not called
|
||||
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call trackError for error messages", () => {
|
||||
// Create an error status message
|
||||
const statusMessage: StatusMessage = {
|
||||
status_update: true,
|
||||
type: "error",
|
||||
id: "ERROR_ID",
|
||||
message: "Some error message",
|
||||
};
|
||||
|
||||
// Call the function
|
||||
handleStatusMessage(statusMessage);
|
||||
|
||||
// Verify that trackError was called with the correct parameters
|
||||
expect(trackError).toHaveBeenCalledWith({
|
||||
message: "Some error message",
|
||||
source: "chat",
|
||||
metadata: { msgId: "ERROR_ID" },
|
||||
});
|
||||
|
||||
// Verify that queryClient.invalidateQueries was not called
|
||||
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import ActionType from "#/types/action-type";
|
||||
import { ActionMessage } from "#/types/message";
|
||||
import { useCommandStore } from "#/stores/command-store";
|
||||
|
||||
const mockDispatch = vi.fn();
|
||||
const mockAppendInput = vi.fn();
|
||||
|
||||
vi.mock("#/store", () => ({
|
||||
default: {
|
||||
dispatch: mockDispatch,
|
||||
},
|
||||
}));
|
||||
|
||||
describe("handleActionMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useCommandStore.setState({
|
||||
appendInput: mockAppendInput,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle RUN actions by adding input to terminal", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const runAction: ActionMessage = {
|
||||
id: 1,
|
||||
source: "agent",
|
||||
action: ActionType.RUN,
|
||||
args: {
|
||||
command: "ls -la",
|
||||
},
|
||||
message: "Running command: ls -la",
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Handle the action
|
||||
handleActionMessage(runAction);
|
||||
|
||||
// Check that appendInput was called with the command
|
||||
expect(mockAppendInput).toHaveBeenCalledWith("ls -la");
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle RUN_IPYTHON actions as no-op (Jupyter removed)", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const ipythonAction: ActionMessage = {
|
||||
id: 2,
|
||||
source: "agent",
|
||||
action: ActionType.RUN_IPYTHON,
|
||||
args: {
|
||||
code: "print('Hello from Jupyter!')",
|
||||
},
|
||||
message:
|
||||
"Running Python code interactively: print('Hello from Jupyter!')",
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Handle the action
|
||||
handleActionMessage(ipythonAction);
|
||||
|
||||
// Jupyter functionality has been removed, so nothing should be called
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not process hidden actions", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const hiddenAction: ActionMessage = {
|
||||
id: 3,
|
||||
source: "agent",
|
||||
action: ActionType.RUN,
|
||||
args: {
|
||||
command: "secret command",
|
||||
hidden: "true",
|
||||
},
|
||||
message: "Running command: secret command",
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Handle the action
|
||||
handleActionMessage(hiddenAction);
|
||||
|
||||
// Check that nothing was dispatched or called
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock the store module
|
||||
vi.mock("#/store", () => ({
|
||||
default: {
|
||||
dispatch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("handleObservationMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it.todo("updates browser state when receiving a browse observation");
|
||||
|
||||
it.todo(
|
||||
"updates browser state when receiving a browse_interactive observation",
|
||||
);
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Card } from "#/ui/card";
|
||||
|
||||
describe("Card", () => {
|
||||
it("should render children", () => {
|
||||
render(<Card>Card Content</Card>);
|
||||
|
||||
expect(screen.getByText("Card Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with testId", () => {
|
||||
render(<Card testId="test-card">Content</Card>);
|
||||
|
||||
expect(screen.getByTestId("test-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Prop-passthrough contract: consumer-supplied className must land on the
|
||||
// element.
|
||||
it("should apply custom className", () => {
|
||||
render(
|
||||
<Card testId="test-card" className="custom-class">
|
||||
Content
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-card")).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useSuggestedTasks } from "../src/hooks/query/use-suggested-tasks";
|
||||
import { useShouldShowUserFeatures } from "../src/hooks/use-should-show-user-features";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("../src/hooks/use-should-show-user-features");
|
||||
vi.mock("#/api/suggestions-service/suggestions-service.api", () => ({
|
||||
SuggestionsService: {
|
||||
getSuggestedTasks: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUseShouldShowUserFeatures = vi.mocked(useShouldShowUserFeatures);
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
describe("useSuggestedTasks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default to disabled
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should be disabled when useShouldShowUserFeatures returns false", () => {
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useSuggestedTasks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it("should be enabled when useShouldShowUserFeatures returns true", () => {
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useSuggestedTasks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// When enabled, the query should be loading/fetching
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { SuggestedTask, SuggestedTaskGroup } from "#/utils/types";
|
||||
import { groupSuggestedTasks } from "#/utils/group-suggested-tasks";
|
||||
|
||||
const rawTasks: SuggestedTask[] = [
|
||||
{
|
||||
issue_number: 1,
|
||||
repo: "repo1",
|
||||
title: "Task 1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 2,
|
||||
repo: "repo1",
|
||||
title: "Task 2",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 3,
|
||||
repo: "repo2",
|
||||
title: "Task 3",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 4,
|
||||
repo: "repo2",
|
||||
title: "Task 4",
|
||||
task_type: "OPEN_ISSUE",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 5,
|
||||
repo: "repo3",
|
||||
title: "Task 5",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
|
||||
const groupedTasks: SuggestedTaskGroup[] = [
|
||||
{
|
||||
title: "repo1",
|
||||
tasks: [
|
||||
{
|
||||
issue_number: 1,
|
||||
repo: "repo1",
|
||||
title: "Task 1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 2,
|
||||
repo: "repo1",
|
||||
title: "Task 2",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "repo2",
|
||||
tasks: [
|
||||
{
|
||||
issue_number: 3,
|
||||
repo: "repo2",
|
||||
title: "Task 3",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 4,
|
||||
repo: "repo2",
|
||||
title: "Task 4",
|
||||
task_type: "OPEN_ISSUE",
|
||||
git_provider: "github",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "repo3",
|
||||
tasks: [
|
||||
{
|
||||
issue_number: 5,
|
||||
repo: "repo3",
|
||||
title: "Task 5",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
test("groupSuggestedTasks", () => {
|
||||
expect(groupSuggestedTasks(rawTasks)).toEqual(groupedTasks);
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from "./types";
|
||||
export * from "./storage";
|
||||
export * from "./default-backend";
|
||||
export * from "./active-store";
|
||||
export * from "./auth";
|
||||
@@ -1,36 +0,0 @@
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
import { callCloudProxy } from "./proxy";
|
||||
|
||||
function getActiveCloudBackend(): Backend {
|
||||
const active = getActiveBackend().backend;
|
||||
if (active.kind !== "cloud") {
|
||||
throw new Error("Cloud suggested-tasks call requires a cloud backend.");
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
export async function getCloudSuggestedTasks(args: {
|
||||
pageId?: string;
|
||||
limit?: number;
|
||||
}): Promise<{ items: SuggestedTask[]; next_page_id: string | null }> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(args.limit ?? 30));
|
||||
if (args.pageId) params.set("page_id", args.pageId);
|
||||
|
||||
const data = await callCloudProxy<{
|
||||
items: SuggestedTask[];
|
||||
next_page_id: string | null;
|
||||
}>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: `/api/v1/git/suggested-tasks/search?${params.toString()}`,
|
||||
});
|
||||
|
||||
return {
|
||||
items: data?.items ?? [],
|
||||
next_page_id: data?.next_page_id ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import { getCloudSuggestedTasks } from "../cloud/suggestions-service.api";
|
||||
|
||||
export class SuggestionsService {
|
||||
/**
|
||||
* Aggregate suggested tasks for the calling user. Cloud exposes
|
||||
* `/api/v1/git/suggested-tasks/search` which the proxy forwards to.
|
||||
* Local agent-server has no equivalent endpoint, so we return an
|
||||
* empty list there — the home page's `<TaskSuggestions />` already
|
||||
* renders nothing when the active backend is local.
|
||||
*/
|
||||
static async getSuggestedTasks(
|
||||
pageId?: string,
|
||||
limit: number = 30,
|
||||
): Promise<SuggestedTask[]> {
|
||||
if (getActiveBackend().backend.kind !== "cloud") {
|
||||
return [];
|
||||
}
|
||||
const page = await getCloudSuggestedTasks({ pageId, limit });
|
||||
return page.items;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export { UserAssistantEventMessage } from "./user-assistant-event-message";
|
||||
export { ObservationPairEventMessage } from "./observation-pair-event-message";
|
||||
export { ErrorEventMessage } from "./error-event-message";
|
||||
export { FinishEventMessage } from "./finish-event-message";
|
||||
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
||||
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import React from "react";
|
||||
import { ActionEvent } from "#/types/agent-server/core";
|
||||
import { isActionEvent } from "#/types/agent-server/type-guards";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
|
||||
interface ObservationPairEventMessageProps {
|
||||
event: ActionEvent;
|
||||
}
|
||||
|
||||
export function ObservationPairEventMessage({
|
||||
event,
|
||||
}: ObservationPairEventMessageProps) {
|
||||
if (!isActionEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there's thought content to display
|
||||
const thoughtContent = event.thought
|
||||
.filter((t) => t.type === "text")
|
||||
.map((t) => t.text)
|
||||
.join("\n");
|
||||
|
||||
// Defensive check: ensure action exists and has kind property
|
||||
if (
|
||||
thoughtContent &&
|
||||
event.action?.kind &&
|
||||
event.action.kind !== "ThinkAction"
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage type="agent" message={thoughtContent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./chat";
|
||||
@@ -1,16 +0,0 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
import { extensionModuleCardPillClassName } from "#/utils/extension-module-card-classes";
|
||||
|
||||
interface MetadataChipProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function MetadataChip({ icon, label }: MetadataChipProps) {
|
||||
return (
|
||||
<span className={cn(extensionModuleCardPillClassName, "gap-1")}>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { cn, getStatusClassName } from "#/utils/utils";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-1 rounded uppercase font-semibold",
|
||||
getStatusClassName(status),
|
||||
)}
|
||||
>
|
||||
{status.replace("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { getStatusIcon } from "#/utils/utils";
|
||||
|
||||
interface StatusIconProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function StatusIcon({ status }: StatusIconProps) {
|
||||
return <span className="text-lg">{getStatusIcon(status)}</span>;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskItem } from "./task-item";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface TaskListSectionProps {
|
||||
taskList: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
notes?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function TaskListSection({ taskList }: TaskListSectionProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-clip bg-[var(--oh-surface)] border border-[var(--oh-border)] rounded-[12px] w-full">
|
||||
{/* Header Tabs */}
|
||||
<div className="flex gap-1 items-center border-b border-[var(--oh-border)] h-[41px] px-2 shrink-0">
|
||||
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[var(--oh-muted)]" />
|
||||
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
|
||||
{t(I18nKey.COMMON$TASKS)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{/* Task Items */}
|
||||
<div>
|
||||
{taskList.map((task) => (
|
||||
<TaskItem key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import React from "react";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useOptionalConversationId } from "#/hooks/use-conversation-id";
|
||||
import { ComboboxCaretInline } from "#/ui/combobox-caret";
|
||||
import { chatInputPillButtonClassName } from "#/utils/form-control-classes";
|
||||
import { ToolsContextMenu } from "./tools-context-menu";
|
||||
import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-context-menu";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { SystemMessageModal } from "../conversation-panel/system-message-modal";
|
||||
import { SkillsModal } from "../conversation-panel/skills-modal";
|
||||
import { PluginsModal } from "../conversation-panel/plugins-modal";
|
||||
import { HooksModal } from "../conversation-panel/hooks-modal";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export function Tools() {
|
||||
const { t } = useTranslation("openhands");
|
||||
// Optional because this control also renders inside the home-page chat
|
||||
// input shell, before any conversation exists.
|
||||
const { conversationId } = useOptionalConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
|
||||
|
||||
const {
|
||||
handleShowAgentTools,
|
||||
handleShowSkills,
|
||||
handleShowPlugins,
|
||||
handleShowHooks,
|
||||
systemModalVisible,
|
||||
setSystemModalVisible,
|
||||
skillsModalVisible,
|
||||
setSkillsModalVisible,
|
||||
pluginsModalVisible,
|
||||
setPluginsModalVisible,
|
||||
hooksModalVisible,
|
||||
setHooksModalVisible,
|
||||
systemMessage,
|
||||
shouldShowAgentTools,
|
||||
shouldShowHooks,
|
||||
shouldShowPlugins,
|
||||
} = useConversationNameContextMenu({
|
||||
conversationId: conversationId ?? undefined,
|
||||
executionStatus: conversation?.execution_status,
|
||||
showOptions: true,
|
||||
onContextMenuToggle: setContextMenuOpen,
|
||||
});
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenuOpen(!contextMenuOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(chatInputPillButtonClassName, "group flex")}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Wrench
|
||||
className="h-[13px] w-[13px] shrink-0"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm font-normal leading-5">
|
||||
{t(I18nKey.MICROAGENTS_MODAL$TOOLS)}
|
||||
</span>
|
||||
<ComboboxCaretInline isOpen={contextMenuOpen} />
|
||||
</button>
|
||||
{contextMenuOpen && (
|
||||
<ToolsContextMenu
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
onShowSkills={handleShowSkills}
|
||||
onShowPlugins={handleShowPlugins}
|
||||
onShowHooks={handleShowHooks}
|
||||
onShowAgentTools={handleShowAgentTools}
|
||||
shouldShowAgentTools={shouldShowAgentTools}
|
||||
shouldShowHooks={shouldShowHooks}
|
||||
shouldShowPlugins={shouldShowPlugins}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* System Message Modal */}
|
||||
<SystemMessageModal
|
||||
isOpen={systemModalVisible}
|
||||
onClose={() => setSystemModalVisible(false)}
|
||||
systemMessage={systemMessage || null}
|
||||
/>
|
||||
|
||||
{/* Skills Modal */}
|
||||
{skillsModalVisible && (
|
||||
<SkillsModal onClose={() => setSkillsModalVisible(false)} />
|
||||
)}
|
||||
|
||||
{/* Plugins Modal */}
|
||||
{pluginsModalVisible && (
|
||||
<PluginsModal onClose={() => setPluginsModalVisible(false)} />
|
||||
)}
|
||||
|
||||
{/* Hooks Modal */}
|
||||
{hooksModalVisible && (
|
||||
<HooksModal onClose={() => setHooksModalVisible(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
|
||||
import {
|
||||
CloudNewConversationMenu,
|
||||
type CloudNewConversationMenuTriggerProps,
|
||||
} from "./cloud-new-conversation-menu";
|
||||
import {
|
||||
LocalNewConversationMenu,
|
||||
type LocalNewConversationMenuTriggerProps,
|
||||
} from "./local-new-conversation-menu";
|
||||
|
||||
interface NewConversationButtonProps {
|
||||
/**
|
||||
* Render the trigger as a "+" icon-only button (used by the collapsed
|
||||
* sidebar). The popover content is unchanged; only the trigger pill
|
||||
* collapses.
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar "+ New Conversation" trigger.
|
||||
*
|
||||
* The popover content depends on the active backend: local backends operate
|
||||
* on workspace folders so we surface the workspace picker, while cloud
|
||||
* backends operate on git repositories so we surface a repository picker.
|
||||
*
|
||||
* The trigger pill (the "+" button itself) is identical for both variants;
|
||||
* only the menu component differs. Both `Cloud/LocalNewConversationMenu`
|
||||
* accept structurally identical trigger props, so a single `renderTrigger`
|
||||
* callback can satisfy either menu's type signature.
|
||||
*/
|
||||
export function NewConversationButton({
|
||||
compact = false,
|
||||
}: NewConversationButtonProps = {}) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const newConversationLabel = t(I18nKey.SIDEBAR$NEW_CONVERSATION);
|
||||
const isCloud = useActiveBackend().backend.kind === "cloud";
|
||||
|
||||
const renderTrigger = React.useCallback(
|
||||
(
|
||||
tp:
|
||||
| CloudNewConversationMenuTriggerProps
|
||||
| LocalNewConversationMenuTriggerProps,
|
||||
) => {
|
||||
const triggerButton = (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="new-conversation-button"
|
||||
{...tp}
|
||||
aria-label={compact ? newConversationLabel : undefined}
|
||||
className={cn(
|
||||
"flex items-center rounded-md cursor-pointer transition-colors",
|
||||
"text-sm text-white bg-[var(--oh-surface)]/60 hover:bg-[var(--oh-surface-raised)]",
|
||||
"border border-[var(--oh-border)]",
|
||||
compact
|
||||
? "justify-center w-10 h-10 p-0 mx-auto"
|
||||
: "gap-1.5 w-full px-3 py-2",
|
||||
)}
|
||||
>
|
||||
<Plus width={16} height={16} className="shrink-0" />
|
||||
{!compact && newConversationLabel}
|
||||
</button>
|
||||
);
|
||||
|
||||
return compact ? (
|
||||
<StyledTooltip content={newConversationLabel} placement="right">
|
||||
{triggerButton}
|
||||
</StyledTooltip>
|
||||
) : (
|
||||
triggerButton
|
||||
);
|
||||
},
|
||||
[compact, newConversationLabel],
|
||||
);
|
||||
|
||||
const wrapperClassName = cn(compact && "flex justify-center");
|
||||
const popoverClassName = compact ? "left-0 w-[260px]" : "left-0 right-0";
|
||||
|
||||
return isCloud ? (
|
||||
<CloudNewConversationMenu
|
||||
className={wrapperClassName}
|
||||
popoverClassName={popoverClassName}
|
||||
trigger={renderTrigger}
|
||||
/>
|
||||
) : (
|
||||
<LocalNewConversationMenu
|
||||
className={wrapperClassName}
|
||||
popoverClassName={popoverClassName}
|
||||
trigger={renderTrigger}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function GuideMessage() {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
return (
|
||||
<div className="w-fit flex flex-col md:flex-row items-start md:items-center justify-center gap-1 rounded-[12px] bg-tertiary leading-5 text-white text-[15px] font-normal m-1 md:h-9.5 px-4 pb-1 md:px-[15px] md:py-0">
|
||||
<span className="">{t(I18nKey.HOME$GUIDE_MESSAGE_TITLE)} </span>
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/getting-started"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="underline">{t(I18nKey.COMMON$CLICK_HERE)}</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { GuideMessage } from "./guide-message";
|
||||
import { HomeHeaderTitle } from "./home-header-title";
|
||||
|
||||
export function HomeHeader() {
|
||||
return (
|
||||
<header className="flex flex-col items-center gap-12">
|
||||
<GuideMessage />
|
||||
<HomeHeaderTitle />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useNavigation } from "#/context/navigation-context";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import PlusIcon from "#/icons/u-plus.svg?react";
|
||||
|
||||
export function NewConversation() {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
const { navigate } = useNavigation();
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = useCreateConversation();
|
||||
const isCreatingConversationElsewhere = useIsCreatingConversation();
|
||||
|
||||
// We check for isSuccess because the app might require time to render
|
||||
// into the new conversation screen after the conversation is created.
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
return (
|
||||
<section className="w-full min-h-[286px] md:min-h-auto flex flex-col rounded-[12px] p-[20px] gap-[10px] border border-[var(--oh-border)] bg-[var(--oh-surface)] relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<PlusIcon width={17} height={14} />
|
||||
<span className="leading-5 font-bold text-base text-white">
|
||||
{t(I18nKey.COMMON$START_FROM_SCRATCH)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="leading-[22px] text-sm font-normal text-white">
|
||||
{t(I18nKey.HOME$NEW_PROJECT_DESCRIPTION)}
|
||||
</span>
|
||||
</div>
|
||||
<BrandButton
|
||||
testId="launch-new-conversation-button"
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
createConversation(
|
||||
{ entryPoint: "home_start_from_scratch" },
|
||||
{
|
||||
onSuccess: (data) =>
|
||||
navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
)
|
||||
}
|
||||
isDisabled={isCreatingConversation}
|
||||
className="w-auto absolute bottom-5 left-5 right-5"
|
||||
>
|
||||
{!isCreatingConversation && t(I18nKey.COMMON$NEW_CONVERSATION)}
|
||||
{isCreatingConversation && t(I18nKey.HOME$LOADING)}
|
||||
</BrandButton>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrandButton } from "../../settings/brand-button";
|
||||
import { useNavigation } from "#/context/navigation-context";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function CreateConversationButton() {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { navigate } = useNavigation();
|
||||
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = useCreateConversation();
|
||||
const isCreatingConversationElsewhere = useIsCreatingConversation();
|
||||
|
||||
// We check for isSuccess because the app might require time to render
|
||||
// into the new conversation screen after the conversation is created.
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
const handleCreateConversation = () => {
|
||||
createConversation(
|
||||
{ entryPoint: "home_new_conversation_button" },
|
||||
{
|
||||
onSuccess: (data) => navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BrandButton
|
||||
testId="launch-new-conversation-button"
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={handleCreateConversation}
|
||||
isDisabled={isCreatingConversation}
|
||||
className="w-auto absolute bottom-5 left-5 right-5"
|
||||
>
|
||||
{!isCreatingConversation && t(I18nKey.COMMON$NEW_CONVERSATION)}
|
||||
{isCreatingConversation && t(I18nKey.HOME$LOADING)}
|
||||
</BrandButton>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import PlusIcon from "#/icons/u-plus.svg?react";
|
||||
import { CardTitle } from "#/ui/card-title";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { CreateConversationButton } from "./create-conversation-button";
|
||||
import { Card } from "#/ui/card";
|
||||
|
||||
export function NewConversation() {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
return (
|
||||
<Card className="flex-col p-5 gap-2.5 min-h-[286px] md:min-h-auto w-full">
|
||||
<CardTitle icon={<PlusIcon width={17} height={14} />}>
|
||||
{t(I18nKey.COMMON$START_FROM_SCRATCH)}
|
||||
</CardTitle>
|
||||
<Typography.Text>
|
||||
{t(I18nKey.HOME$NEW_PROJECT_DESCRIPTION)}
|
||||
</Typography.Text>
|
||||
<CreateConversationButton />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { TaskIssueNumber } from "./task-issue-number";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useNavigation } from "#/context/navigation-context";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
const getTaskTypeMap = (
|
||||
t: (key: string) => string,
|
||||
): Record<SuggestedTask["task_type"], string> => ({
|
||||
FAILING_CHECKS: t(I18nKey.HOME$FIX_FAILING_CHECKS),
|
||||
MERGE_CONFLICTS: t(I18nKey.HOME$RESOLVE_MERGE_CONFLICTS),
|
||||
OPEN_ISSUE: t(I18nKey.HOME$OPEN_ISSUE),
|
||||
UNRESOLVED_COMMENTS: t(I18nKey.HOME$RESOLVE_UNRESOLVED_COMMENTS),
|
||||
});
|
||||
|
||||
interface TaskCardProps {
|
||||
task: SuggestedTask;
|
||||
}
|
||||
|
||||
export function TaskCard({ task }: TaskCardProps) {
|
||||
const enqueuePendingMessage = useOptimisticUserMessageStore(
|
||||
(state) => state.enqueuePendingMessage,
|
||||
);
|
||||
const { mutate: createConversation } = useCreateConversation();
|
||||
const isCreatingConversation = useIsCreatingConversation();
|
||||
const { t } = useTranslation("openhands");
|
||||
const { navigate } = useNavigation();
|
||||
|
||||
const handleLaunchConversation = () =>
|
||||
createConversation(
|
||||
{
|
||||
repository: {
|
||||
name: task.repo,
|
||||
gitProvider: task.git_provider,
|
||||
},
|
||||
suggestedTask: task,
|
||||
entryPoint: "home_task_card",
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
// Enqueue the pending message after the new conversation exists so
|
||||
// it can be tagged with the conversation id and only show up in the
|
||||
// target conversation's chat (not in whatever convo the user was
|
||||
// looking at when they clicked the task).
|
||||
enqueuePendingMessage({
|
||||
conversationId: data.conversation_id,
|
||||
text: t(I18nKey.TASK$ADDRESSING_TASK),
|
||||
});
|
||||
navigate(`/conversations/${data.conversation_id}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Determine the correct URL format based on git provider
|
||||
let href: string;
|
||||
if (task.git_provider === "gitlab") {
|
||||
const issueType =
|
||||
task.task_type === "OPEN_ISSUE" ? "issues" : "merge_requests";
|
||||
href = `https://gitlab.com/${task.repo}/-/${issueType}/${task.issue_number}`;
|
||||
} else if (task.git_provider === "bitbucket") {
|
||||
const issueType =
|
||||
task.task_type === "OPEN_ISSUE" ? "issues" : "pull-requests";
|
||||
href = `https://bitbucket.org/${task.repo}/${issueType}/${task.issue_number}`;
|
||||
} else if (task.git_provider === "azure_devops") {
|
||||
// Azure DevOps URL format: https://dev.azure.com/{organization}/{project}/_workitems/edit/{id}
|
||||
// or https://dev.azure.com/{organization}/{project}/_git/{repo}/pullrequest/{id}
|
||||
const azureDevOpsBaseUrl = "https://dev.azure.com";
|
||||
if (task.task_type === "OPEN_ISSUE") {
|
||||
href = `${azureDevOpsBaseUrl}/${task.repo}/_workitems/edit/${task.issue_number}`;
|
||||
} else {
|
||||
href = `${azureDevOpsBaseUrl}/${task.repo}/_git/${task.repo.split("/")[1]}/pullrequest/${task.issue_number}`;
|
||||
}
|
||||
} else {
|
||||
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
|
||||
href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="task-launch-button"
|
||||
className={cn(
|
||||
"w-full p-[14px] text-left flex items-center justify-between cursor-pointer hover:bg-[var(--oh-interactive-hover)] transition-all duration-300 rounded-lg",
|
||||
isCreatingConversation && "cursor-not-allowed",
|
||||
)}
|
||||
disabled={isCreatingConversation}
|
||||
onClick={handleLaunchConversation}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 w-full">
|
||||
<TaskIssueNumber issueNumber={task.issue_number} href={href} />
|
||||
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<span className="text-xs text-white leading-6 font-normal truncate">
|
||||
{getTaskTypeMap(t)[task.task_type]}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs text-[var(--oh-muted)] leading-4 font-normal max-w-70 truncate"
|
||||
title={task.title}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
|
||||
import { TaskCard } from "./task-card";
|
||||
import { TaskItemTitle } from "./task-item-title";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
|
||||
interface TaskGroupProps {
|
||||
title: string;
|
||||
tasks: SuggestedTask[];
|
||||
}
|
||||
|
||||
export function TaskGroup({ title, tasks }: TaskGroupProps) {
|
||||
const gitProvider = tasks.length > 0 ? tasks[0].git_provider : null;
|
||||
|
||||
return (
|
||||
<div className="text-content-2 px-[14px]">
|
||||
<div className="flex items-center gap-2 border-b-1 border-[var(--oh-border-input)] mb-2">
|
||||
{gitProvider === "github" && <FaGithub size={14} />}
|
||||
{gitProvider === "gitlab" && <FaGitlab />}
|
||||
{gitProvider === "bitbucket" && <FaBitbucket />}
|
||||
<TaskItemTitle>{title}</TaskItemTitle>
|
||||
</div>
|
||||
|
||||
<ul className="w-full text-sm">
|
||||
{tasks.map((task) => (
|
||||
<li key={task.issue_number} className="w-full">
|
||||
<TaskCard task={task} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
interface TaskIssueNumberProps {
|
||||
issueNumber: number;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function TaskIssueNumber({ href, issueNumber }: TaskIssueNumberProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="task-id"
|
||||
>
|
||||
<span className="text-xs text-[var(--oh-muted)] leading-4 font-normal">
|
||||
#{issueNumber}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function TaskItemTitle({ children: title }: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className="py-3">
|
||||
<h3 className="text-xs text-white leading-6 font-normal">{title}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
function TaskCardSkeleton() {
|
||||
return (
|
||||
<li className="py-3 flex items-center pr-6">
|
||||
<div className="h-3 w-8 skeleton !rounded-sm" />
|
||||
|
||||
<div className="w-full pl-8">
|
||||
<div className="h-3 w-24 skeleton mb-2 !rounded-sm" />
|
||||
<div className={cn("h-3 skeleton w-3/4 !rounded-sm")} />
|
||||
</div>
|
||||
|
||||
<div className="h-3 w-16 skeleton !rounded-sm" />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskGroupSkeletonProps {
|
||||
items?: number;
|
||||
}
|
||||
|
||||
function TaskGroupSkeleton({ items = 3 }: TaskGroupSkeletonProps) {
|
||||
return (
|
||||
<div data-testid="task-group-skeleton">
|
||||
<div className="py-3 border-b border-[var(--oh-border-input)] pt-3.5">
|
||||
<div className="h-3 w-40 skeleton !rounded-sm" />
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{Array.from({ length: items }).map((_, index) => (
|
||||
<TaskCardSkeleton key={index} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="w-15 h-3 skeleton !rounded-sm mb-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskSuggestionsSkeleton() {
|
||||
return <TaskGroupSkeleton />;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskGroup } from "./task-group";
|
||||
import { useSuggestedTasks } from "#/hooks/query/use-suggested-tasks";
|
||||
import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton";
|
||||
import { cn, getDisplayedTaskGroups, getTotalTaskCount } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
|
||||
interface TaskSuggestionsProps {
|
||||
filterFor?: GitRepository | null;
|
||||
}
|
||||
|
||||
export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isCloud = useActiveBackend().backend.kind === "cloud";
|
||||
|
||||
const { data: tasks, isLoading } = useSuggestedTasks();
|
||||
|
||||
if (!isCloud) return null;
|
||||
|
||||
const suggestedTasks = filterFor
|
||||
? tasks?.filter(
|
||||
(element) =>
|
||||
element.title === filterFor.full_name &&
|
||||
!!element.tasks.find(
|
||||
(task) => task.git_provider === filterFor.git_provider,
|
||||
),
|
||||
)
|
||||
: tasks;
|
||||
|
||||
const hasSuggestedTasks = suggestedTasks && suggestedTasks.length > 0;
|
||||
|
||||
// Get the task groups to display based on expanded state
|
||||
const displayedTaskGroups = getDisplayedTaskGroups(
|
||||
suggestedTasks,
|
||||
isExpanded,
|
||||
);
|
||||
|
||||
// Check if there are more individual tasks to show
|
||||
const hasMoreTasks = getTotalTaskCount(suggestedTasks) > 3;
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="task-suggestions"
|
||||
className="flex flex-1 min-w-0 flex-col"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
!hasSuggestedTasks && "mb-[14px]",
|
||||
)}
|
||||
>
|
||||
<h3 className="text-xs leading-4 text-white font-medium py-[14px] pl-[14px]">
|
||||
{t(I18nKey.TASKS$SUGGESTED_TASKS)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{isLoading && (
|
||||
<div className="px-[14px]">
|
||||
<TaskSuggestionsSkeleton />
|
||||
</div>
|
||||
)}
|
||||
{!hasSuggestedTasks && !isLoading && (
|
||||
<Typography.Text className="text-xs leading-4 text-white font-medium px-[14px]">
|
||||
{t(I18nKey.TASKS$NO_TASKS_AVAILABLE)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
displayedTaskGroups &&
|
||||
displayedTaskGroups.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<div className="transition-all duration-300 ease-in-out overflow-y-auto custom-scrollbar">
|
||||
<div className="flex flex-col">
|
||||
{displayedTaskGroups.map((taskGroup, index) => (
|
||||
<TaskGroup
|
||||
key={index}
|
||||
title={decodeURIComponent(taskGroup.title)}
|
||||
tasks={taskGroup.tasks}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && hasMoreTasks && (
|
||||
<div className="flex justify-start mt-6 mb-8 ml-[14px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="text-xs leading-4 text-content-2 font-normal cursor-pointer hover:underline"
|
||||
>
|
||||
{isExpanded
|
||||
? t(I18nKey.COMMON$VIEW_LESS)
|
||||
: t(I18nKey.COMMON$VIEW_MORE)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import React from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAgentServerFormDefaults } from "#/api/agent-server-config";
|
||||
import {
|
||||
getRegisteredBackends,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import {
|
||||
SEEDED_DEFAULT_BACKEND_ID,
|
||||
DEFAULT_LOCAL_BACKEND_NAME,
|
||||
} from "#/api/backend-registry/default-backend";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { buildAgentCanvasPath } from "#/utils/base-path";
|
||||
import { BrandButton } from "./brand-button";
|
||||
import { SettingsInput } from "./settings-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
type AgentServerConnectionFormVariant = "settings" | "onboarding";
|
||||
|
||||
interface AgentServerConnectionFormProps {
|
||||
className?: string;
|
||||
formClassName?: string;
|
||||
variant?: AgentServerConnectionFormVariant;
|
||||
showSectionHeader?: boolean;
|
||||
}
|
||||
|
||||
export function AgentServerConnectionForm({
|
||||
className,
|
||||
formClassName,
|
||||
variant = "onboarding",
|
||||
showSectionHeader,
|
||||
}: AgentServerConnectionFormProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const defaults = React.useMemo(() => getAgentServerFormDefaults(), []);
|
||||
const [baseUrl, setBaseUrl] = React.useState(defaults.baseUrl);
|
||||
const [sessionApiKey, setSessionApiKey] = React.useState(
|
||||
defaults.sessionApiKey,
|
||||
);
|
||||
|
||||
const formIsClean =
|
||||
baseUrl === defaults.baseUrl && sessionApiKey === defaults.sessionApiKey;
|
||||
const isOnboarding = variant === "onboarding";
|
||||
const shouldShowSectionHeader = showSectionHeader ?? isOnboarding;
|
||||
|
||||
const reconnect = () => {
|
||||
window.location.assign(buildAgentCanvasPath("/"));
|
||||
};
|
||||
|
||||
const syncDefaultBackendInRegistry = () => {
|
||||
const trimmedHost = baseUrl.trim();
|
||||
if (!trimmedHost) return;
|
||||
|
||||
const trimmedKey = sessionApiKey.trim();
|
||||
const current = getRegisteredBackends();
|
||||
const defaultEntry: Backend = {
|
||||
id: SEEDED_DEFAULT_BACKEND_ID,
|
||||
name: DEFAULT_LOCAL_BACKEND_NAME,
|
||||
host: trimmedHost,
|
||||
apiKey: trimmedKey,
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
const existingIndex = current.findIndex(
|
||||
(b) => b.id === SEEDED_DEFAULT_BACKEND_ID,
|
||||
);
|
||||
if (existingIndex === -1) {
|
||||
setRegisteredBackends([defaultEntry, ...current]);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = current.slice();
|
||||
next[existingIndex] = {
|
||||
...current[existingIndex],
|
||||
host: trimmedHost,
|
||||
apiKey: trimmedKey,
|
||||
};
|
||||
setRegisteredBackends(next);
|
||||
};
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
syncDefaultBackendInRegistry();
|
||||
|
||||
reconnect();
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid="agent-server-connection-form"
|
||||
onSubmit={onSubmit}
|
||||
className={cn("flex h-full flex-col", className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-5",
|
||||
isOnboarding &&
|
||||
"rounded-3xl border border-white/10 bg-[var(--oh-surface-deep)]/80 p-6 shadow-2xl",
|
||||
formClassName,
|
||||
)}
|
||||
>
|
||||
{shouldShowSectionHeader ? (
|
||||
<div>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.24em] text-primary">
|
||||
{t(I18nKey.SETTINGS$AGENT_SERVER_CONNECTION_DETAILS_TITLE)}
|
||||
</p>
|
||||
<p className="mt-3 w-full min-w-0 text-sm leading-7 text-[var(--oh-muted)]">
|
||||
{t(I18nKey.SETTINGS$AGENT_SERVER_CONNECTION_DETAILS_DESCRIPTION)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SettingsInput
|
||||
testId="agent-server-url-input"
|
||||
name="agent-server-url-input"
|
||||
type="text"
|
||||
label={t(I18nKey.SETTINGS$AGENT_SERVER_URL)}
|
||||
value={baseUrl}
|
||||
onChange={setBaseUrl}
|
||||
placeholder={t(I18nKey.SETTINGS$AGENT_SERVER_URL_PLACEHOLDER)}
|
||||
className="w-full min-w-0"
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
testId="agent-server-api-key-input"
|
||||
name="agent-server-api-key-input"
|
||||
type="password"
|
||||
label={t(I18nKey.SETTINGS$AGENT_SERVER_API_KEY)}
|
||||
value={sessionApiKey}
|
||||
onChange={setSessionApiKey}
|
||||
placeholder={t(I18nKey.SETTINGS$AGENT_SERVER_API_KEY_PLACEHOLDER)}
|
||||
showOptionalTag
|
||||
className="w-full min-w-0"
|
||||
/>
|
||||
|
||||
<p className="w-full min-w-0 text-xs leading-6 text-[var(--oh-text-subtle)]">
|
||||
{t(I18nKey.SETTINGS$AGENT_SERVER_BROWSER_ONLY_NOTE)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<BrandButton
|
||||
testId="retry-connection-button"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={reconnect}
|
||||
startContent={<RefreshCw className="size-4" />}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$AGENT_SERVER_RETRY_CONNECTION)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isDisabled={formIsClean}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$SAVE_AND_RECONNECT)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import type { MCPServerConfig } from "#/types/mcp-server";
|
||||
|
||||
export function MCPServerListItem({
|
||||
server,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
server: MCPServerConfig;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
const getServerTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case "sse":
|
||||
return t(I18nKey.SETTINGS$MCP_SERVER_TYPE_SSE);
|
||||
case "stdio":
|
||||
return t(I18nKey.SETTINGS$MCP_SERVER_TYPE_STDIO);
|
||||
case "shttp":
|
||||
return t(I18nKey.SETTINGS$MCP_SERVER_TYPE_SHTTP);
|
||||
default:
|
||||
return type.toUpperCase();
|
||||
}
|
||||
};
|
||||
|
||||
const getServerDescription = (serverConfig: MCPServerConfig) => {
|
||||
if (serverConfig.type === "stdio") {
|
||||
if (serverConfig.command) {
|
||||
const args =
|
||||
serverConfig.args && serverConfig.args.length > 0
|
||||
? ` ${serverConfig.args.join(" ")}`
|
||||
: "";
|
||||
return `${serverConfig.command}${args}`;
|
||||
}
|
||||
return serverConfig.name || "";
|
||||
}
|
||||
if (
|
||||
(serverConfig.type === "sse" || serverConfig.type === "shttp") &&
|
||||
serverConfig.url
|
||||
) {
|
||||
return serverConfig.url;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const serverName = server.type === "stdio" ? server.name : server.url;
|
||||
const serverDescription = getServerDescription(server);
|
||||
|
||||
return (
|
||||
<tr
|
||||
data-testid="mcp-server-item"
|
||||
className="grid grid-cols-[minmax(0,0.25fr)_120px_minmax(0,1fr)_120px] gap-4 items-start border-t border-[var(--oh-border-subtle)]"
|
||||
>
|
||||
<td
|
||||
className="px-3 py-2 text-sm text-content-2 truncate min-w-0"
|
||||
title={serverName}
|
||||
>
|
||||
{serverName}
|
||||
</td>
|
||||
|
||||
<td className="px-3 py-2 text-sm text-content-2 whitespace-nowrap">
|
||||
{getServerTypeLabel(server.type)}
|
||||
</td>
|
||||
|
||||
<td
|
||||
className="px-3 py-2 text-sm text-content-2 opacity-80 min-w-0 truncate"
|
||||
title={serverDescription}
|
||||
>
|
||||
<span className="inline-block max-w-full align-bottom">
|
||||
{serverDescription}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="flex items-start justify-end gap-0.5 whitespace-nowrap px-3 py-2">
|
||||
<button
|
||||
data-testid="edit-mcp-server-button"
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={`Edit ${serverName}`}
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-md p-1 text-muted transition-colors hover:bg-interactive-hover hover:text-white"
|
||||
>
|
||||
<Pencil aria-hidden className="size-4" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
data-testid="delete-mcp-server-button"
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label={`Delete ${serverName}`}
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-md p-1 text-muted transition-colors hover:bg-interactive-hover hover:text-white"
|
||||
>
|
||||
<Trash2 aria-hidden className="size-4" strokeWidth={2} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MCPServerListItem } from "./mcp-server-list-item";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { extensionModuleEmptyStateClassName } from "#/utils/extension-module-card-classes";
|
||||
import type { MCPServerConfig } from "#/types/mcp-server";
|
||||
|
||||
interface MCPServerListProps {
|
||||
servers: MCPServerConfig[];
|
||||
onEdit: (server: MCPServerConfig) => void;
|
||||
onDelete: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export function MCPServerList({
|
||||
servers,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: MCPServerListProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className={extensionModuleEmptyStateClassName}>
|
||||
<p className="text-content-2 text-sm">
|
||||
{t(I18nKey.SETTINGS$MCP_NO_SERVERS)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-[var(--oh-border)] rounded-md overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-base-tertiary">
|
||||
<tr className="grid grid-cols-[minmax(0,0.25fr)_120px_minmax(0,1fr)_120px] gap-4 items-start">
|
||||
<th className="px-3 py-2 text-left text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$NAME)}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$MCP_SERVER_TYPE)}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$MCP_SERVER_DETAILS)}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$ACTIONS)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map((server) => (
|
||||
<MCPServerListItem
|
||||
key={server.id}
|
||||
server={server}
|
||||
onEdit={() => onEdit(server)}
|
||||
onDelete={() => onDelete(server.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
VERIFIED_MODELS_GC_TIME,
|
||||
VERIFIED_MODELS_QUERY_KEY,
|
||||
VERIFIED_MODELS_STALE_TIME,
|
||||
fetchVerifiedModelsByProvider,
|
||||
} from "./use-verified-models";
|
||||
|
||||
export const useOpenhandsVerifiedModels = () =>
|
||||
useQuery({
|
||||
queryKey: VERIFIED_MODELS_QUERY_KEY,
|
||||
queryFn: fetchVerifiedModelsByProvider,
|
||||
select: (data) => data?.openhands ?? [],
|
||||
staleTime: VERIFIED_MODELS_STALE_TIME,
|
||||
gcTime: VERIFIED_MODELS_GC_TIME,
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
|
||||
import { groupSuggestedTasks } from "#/utils/group-suggested-tasks";
|
||||
import { useShouldShowUserFeatures } from "../use-should-show-user-features";
|
||||
|
||||
export const useSuggestedTasks = () => {
|
||||
const shouldShowUserFeatures = useShouldShowUserFeatures();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["tasks"],
|
||||
queryFn: () => SuggestionsService.getSuggestedTasks(),
|
||||
select: groupSuggestedTasks,
|
||||
enabled: shouldShowUserFeatures,
|
||||
});
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from "react";
|
||||
import { useIsAuthed } from "./query/use-is-authed";
|
||||
import { useUserProviders } from "./use-user-providers";
|
||||
|
||||
export const useShouldShowUserFeatures = (): boolean => {
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (!isAuthed) return false;
|
||||
return providers.length > 0;
|
||||
}, [isAuthed, providers.length]);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
|
||||
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
|
||||
import { SECRETS_HANDLERS } from "./secrets-handlers";
|
||||
import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers";
|
||||
import {
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
|
||||
export const handlers = [
|
||||
...FILE_SERVICE_HANDLERS,
|
||||
...TASK_SUGGESTIONS_HANDLERS,
|
||||
...SECRETS_HANDLERS,
|
||||
...GIT_REPOSITORY_HANDLERS,
|
||||
...SETTINGS_HANDLERS,
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
|
||||
const TASKS_1: SuggestedTask[] = [
|
||||
{
|
||||
issue_number: 6968,
|
||||
title: "Fix merge conflicts",
|
||||
repo: "octocat/hello-world",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
|
||||
const TASKS_2: SuggestedTask[] = [
|
||||
{
|
||||
issue_number: 268,
|
||||
title: "Fix broken CI checks",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 281,
|
||||
title: "Fix issue",
|
||||
repo: "octocat/earth",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 293,
|
||||
title: "Update documentation",
|
||||
repo: "octocat/earth",
|
||||
task_type: "OPEN_ISSUE",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 305,
|
||||
title: "Refactor user service",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 312,
|
||||
title: "Fix styling bug",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 327,
|
||||
title: "Add unit tests",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 331,
|
||||
title: "Implement dark mode",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 345,
|
||||
title: "Optimize build process",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 352,
|
||||
title: "Update dependencies",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_TASKS = [...TASKS_1, ...TASKS_2];
|
||||
|
||||
export const TASK_SUGGESTIONS_HANDLERS = [
|
||||
// Current endpoint with pagination
|
||||
http.get("*/api/v1/git/suggested-tasks/search", async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const limit = url.searchParams.get("limit");
|
||||
const pageId = url.searchParams.get("page_id");
|
||||
|
||||
// Simple pagination: return all items if no pagination params, otherwise apply limit
|
||||
let tasks = [...MOCK_TASKS];
|
||||
if (pageId || limit) {
|
||||
const limitNum = limit ? parseInt(limit, 10) : 30;
|
||||
tasks = tasks.slice(0, limitNum);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
items: tasks,
|
||||
next_page_id: null, // No pagination in mock data
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -1,89 +0,0 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
import { LocalWorkspace, LocalWorkspaceParent } from "#/types/workspace";
|
||||
|
||||
interface MockWorkspacesState {
|
||||
workspaces: LocalWorkspace[];
|
||||
workspaceParents: LocalWorkspaceParent[];
|
||||
}
|
||||
|
||||
const mockWorkspaces: MockWorkspacesState = {
|
||||
workspaces: [],
|
||||
workspaceParents: [],
|
||||
};
|
||||
|
||||
function workspacesResponse() {
|
||||
return {
|
||||
workspaces: mockWorkspaces.workspaces,
|
||||
workspaceParents: mockWorkspaces.workspaceParents,
|
||||
};
|
||||
}
|
||||
|
||||
function readPath(url: URL) {
|
||||
return url.searchParams.get("path") ?? "";
|
||||
}
|
||||
|
||||
export function resetWorkspaceMockData() {
|
||||
mockWorkspaces.workspaces = [];
|
||||
mockWorkspaces.workspaceParents = [];
|
||||
}
|
||||
|
||||
export const WORKSPACE_HANDLERS = [
|
||||
http.get("*/api/workspaces", () => HttpResponse.json(workspacesResponse())),
|
||||
http.post("*/api/workspaces", async ({ request }) => {
|
||||
const body = (await request.json()) as { workspaces?: LocalWorkspace[] };
|
||||
const nextWorkspaces = body.workspaces ?? [];
|
||||
const existingPaths = new Set(
|
||||
mockWorkspaces.workspaces.map((workspace) => workspace.path),
|
||||
);
|
||||
|
||||
for (const workspace of nextWorkspaces) {
|
||||
if (!existingPaths.has(workspace.path)) {
|
||||
mockWorkspaces.workspaces.push(workspace);
|
||||
existingPaths.add(workspace.path);
|
||||
}
|
||||
}
|
||||
|
||||
return HttpResponse.json(workspacesResponse());
|
||||
}),
|
||||
http.delete("*/api/workspaces", ({ request }) => {
|
||||
const path = readPath(new URL(request.url));
|
||||
mockWorkspaces.workspaces = mockWorkspaces.workspaces.filter(
|
||||
(workspace) => workspace.path !== path,
|
||||
);
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
http.post("*/api/workspaces/parents", async ({ request }) => {
|
||||
const body = (await request.json()) as { parents?: LocalWorkspaceParent[] };
|
||||
const nextParents = body.parents ?? [];
|
||||
const existingPaths = new Set(
|
||||
mockWorkspaces.workspaceParents.map((parent) => parent.path),
|
||||
);
|
||||
|
||||
for (const parent of nextParents) {
|
||||
if (!existingPaths.has(parent.path)) {
|
||||
mockWorkspaces.workspaceParents.push(parent);
|
||||
existingPaths.add(parent.path);
|
||||
}
|
||||
}
|
||||
|
||||
return HttpResponse.json(workspacesResponse());
|
||||
}),
|
||||
http.delete("*/api/workspaces/parents", ({ request }) => {
|
||||
const path = readPath(new URL(request.url));
|
||||
mockWorkspaces.workspaceParents = mockWorkspaces.workspaceParents.filter(
|
||||
(parent) => parent.path !== path,
|
||||
);
|
||||
mockWorkspaces.workspaces = mockWorkspaces.workspaces.filter(
|
||||
(workspace) => workspace.parentPath !== path,
|
||||
);
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
http.post("*/api/auth/workspace-session", () =>
|
||||
HttpResponse.json({ base_url: "/api/conversations/mock/workspace/" }),
|
||||
),
|
||||
http.delete(
|
||||
"*/api/auth/workspace-session",
|
||||
() => new HttpResponse(null, { status: 204 }),
|
||||
),
|
||||
];
|
||||
@@ -1,7 +0,0 @@
|
||||
import { redirect } from "react-router";
|
||||
|
||||
export const clientLoader = () => redirect("/conversations");
|
||||
|
||||
export default function IndexRedirect() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { trackError } from "#/utils/error-handler";
|
||||
import useMetricsStore from "#/stores/metrics-store";
|
||||
import { useStatusStore } from "#/stores/status-store";
|
||||
import ActionType from "#/types/action-type";
|
||||
import {
|
||||
ActionMessage,
|
||||
ObservationMessage,
|
||||
StatusMessage,
|
||||
} from "#/types/message";
|
||||
import { handleObservationMessage } from "./observations";
|
||||
import { useCommandStore } from "#/stores/command-store";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import {
|
||||
ActionSecurityRisk,
|
||||
useSecurityAnalyzerStore,
|
||||
} from "#/stores/security-analyzer-store";
|
||||
|
||||
export function handleActionMessage(message: ActionMessage) {
|
||||
if (message.args?.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update metrics if available
|
||||
if (message.llm_metrics) {
|
||||
const metrics = {
|
||||
cost: message.llm_metrics?.accumulated_cost ?? null,
|
||||
max_budget_per_task: message.llm_metrics?.max_budget_per_task ?? null,
|
||||
usage: message.llm_metrics?.accumulated_token_usage ?? null,
|
||||
};
|
||||
useMetricsStore.getState().setMetrics(metrics);
|
||||
}
|
||||
|
||||
if (message.action === ActionType.RUN) {
|
||||
useCommandStore.getState().appendInput(message.args.command);
|
||||
}
|
||||
|
||||
if ("args" in message && "security_risk" in message.args) {
|
||||
useSecurityAnalyzerStore.getState().appendSecurityAnalyzerInput({
|
||||
id: message.id,
|
||||
args: {
|
||||
command: message.args.command,
|
||||
code: message.args.code,
|
||||
content: message.args.content,
|
||||
security_risk: message.args
|
||||
.security_risk as unknown as ActionSecurityRisk,
|
||||
confirmation_state: message.args.confirmation_state as
|
||||
| "awaiting_confirmation"
|
||||
| "confirmed"
|
||||
| "rejected"
|
||||
| undefined,
|
||||
},
|
||||
message: message.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleStatusMessage(message: StatusMessage) {
|
||||
// Info message with conversation_title indicates new title for conversation
|
||||
if (message.type === "info" && message.conversation_title) {
|
||||
const conversationId = message.message;
|
||||
|
||||
// Invalidate the conversation query to trigger a refetch with the new title
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversation", conversationId],
|
||||
});
|
||||
} else if (message.type === "info") {
|
||||
useStatusStore.getState().setCurStatusMessage({
|
||||
...message,
|
||||
});
|
||||
} else if (message.type === "error") {
|
||||
trackError({
|
||||
message: message.message,
|
||||
source: "chat",
|
||||
metadata: { msgId: message.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAssistantMessage(message: Record<string, unknown>) {
|
||||
if (message.action) {
|
||||
handleActionMessage(message as unknown as ActionMessage);
|
||||
} else if (message.observation) {
|
||||
handleObservationMessage(message as unknown as ObservationMessage);
|
||||
} else if (message.status_update) {
|
||||
handleStatusMessage(message as unknown as StatusMessage);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { ObservationMessage } from "#/types/message";
|
||||
import { useCommandStore } from "#/stores/command-store";
|
||||
import ObservationType from "#/types/observation-type";
|
||||
import { useBrowserStore } from "#/stores/browser-store";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
export function handleObservationMessage(message: ObservationMessage) {
|
||||
switch (message.observation) {
|
||||
case ObservationType.RUN: {
|
||||
if (message.extras.hidden) break;
|
||||
let { content } = message;
|
||||
|
||||
if (content.length > 5000) {
|
||||
const halfLength = 2500;
|
||||
const head = content.slice(0, halfLength);
|
||||
const tail = content.slice(content.length - halfLength);
|
||||
content = `${head}\r\n\n... (truncated ${message.content.length - 5000} characters) ...\r\n\n${tail}`;
|
||||
}
|
||||
|
||||
useCommandStore.getState().appendOutput(content);
|
||||
break;
|
||||
}
|
||||
case ObservationType.BROWSE:
|
||||
case ObservationType.BROWSE_INTERACTIVE:
|
||||
if (
|
||||
message.extras?.screenshot &&
|
||||
typeof message.extras.screenshot === "string"
|
||||
) {
|
||||
useBrowserStore.getState().setScreenshotSrc(message.extras.screenshot);
|
||||
}
|
||||
if (message.extras?.url && typeof message.extras.url === "string") {
|
||||
useBrowserStore.getState().setUrl(message.extras.url);
|
||||
}
|
||||
break;
|
||||
case ObservationType.AGENT_STATE_CHANGED:
|
||||
if (typeof message.extras.agent_state === "string") {
|
||||
useAgentStore
|
||||
.getState()
|
||||
.setCurrentAgentState(message.extras.agent_state as AgentState);
|
||||
}
|
||||
break;
|
||||
case ObservationType.DELEGATE:
|
||||
case ObservationType.READ:
|
||||
case ObservationType.EDIT:
|
||||
case ObservationType.THINK:
|
||||
case ObservationType.NULL:
|
||||
case ObservationType.RECALL:
|
||||
case ObservationType.ERROR:
|
||||
case ObservationType.MCP:
|
||||
case ObservationType.TASK_TRACKING:
|
||||
break; // We don't display the default message for these observations
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (!message.extras?.hidden) {
|
||||
// Convert the message to the appropriate observation type
|
||||
const { observation } = message;
|
||||
|
||||
switch (observation) {
|
||||
case "browse":
|
||||
if (
|
||||
message.extras?.screenshot &&
|
||||
typeof message.extras.screenshot === "string"
|
||||
) {
|
||||
useBrowserStore
|
||||
.getState()
|
||||
.setScreenshotSrc(message.extras.screenshot);
|
||||
}
|
||||
if (message.extras?.url && typeof message.extras.url === "string") {
|
||||
useBrowserStore.getState().setUrl(message.extras.url);
|
||||
}
|
||||
break;
|
||||
case "browse_interactive":
|
||||
if (
|
||||
message.extras?.screenshot &&
|
||||
typeof message.extras.screenshot === "string"
|
||||
) {
|
||||
useBrowserStore
|
||||
.getState()
|
||||
.setScreenshotSrc(message.extras.screenshot);
|
||||
}
|
||||
if (message.extras?.url && typeof message.extras.url === "string") {
|
||||
useBrowserStore.getState().setUrl(message.extras.url);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// For any unhandled observation types, just ignore them
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export enum ActionSecurityRisk {
|
||||
UNKNOWN = -1,
|
||||
LOW = 0,
|
||||
MEDIUM = 1,
|
||||
HIGH = 2,
|
||||
}
|
||||
|
||||
export type SecurityAnalyzerLog = {
|
||||
id: number;
|
||||
content: string;
|
||||
security_risk: ActionSecurityRisk;
|
||||
confirmation_state?: "awaiting_confirmation" | "confirmed" | "rejected";
|
||||
confirmed_changed: boolean;
|
||||
};
|
||||
|
||||
interface SecurityAnalyzerState {
|
||||
logs: SecurityAnalyzerLog[];
|
||||
}
|
||||
|
||||
interface SecurityAnalyzerStore extends SecurityAnalyzerState {
|
||||
appendSecurityAnalyzerInput: (message: {
|
||||
id: number;
|
||||
args: {
|
||||
command?: string;
|
||||
code?: string;
|
||||
content?: string;
|
||||
security_risk: ActionSecurityRisk;
|
||||
confirmation_state?: "awaiting_confirmation" | "confirmed" | "rejected";
|
||||
};
|
||||
message?: string;
|
||||
}) => void;
|
||||
clearLogs: () => void;
|
||||
}
|
||||
|
||||
const initialLogs: SecurityAnalyzerLog[] = [];
|
||||
|
||||
export const useSecurityAnalyzerStore = create<SecurityAnalyzerStore>(
|
||||
(set) => ({
|
||||
logs: initialLogs,
|
||||
appendSecurityAnalyzerInput: (message) =>
|
||||
set((state) => {
|
||||
const log: SecurityAnalyzerLog = {
|
||||
id: message.id,
|
||||
content:
|
||||
message.args.command ||
|
||||
message.args.code ||
|
||||
message.args.content ||
|
||||
message.message ||
|
||||
"",
|
||||
security_risk: message.args.security_risk,
|
||||
confirmation_state: message.args.confirmation_state,
|
||||
confirmed_changed: false,
|
||||
};
|
||||
|
||||
const existingLog = state.logs.find(
|
||||
(stateLog) =>
|
||||
stateLog.id === log.id ||
|
||||
(stateLog.confirmation_state === "awaiting_confirmation" &&
|
||||
stateLog.content === log.content),
|
||||
);
|
||||
|
||||
if (existingLog) {
|
||||
if (existingLog.confirmation_state !== log.confirmation_state) {
|
||||
existingLog.confirmation_state = log.confirmation_state;
|
||||
existingLog.confirmed_changed = true;
|
||||
}
|
||||
return { logs: [...state.logs] }; // Return new array to trigger re-render
|
||||
}
|
||||
return { logs: [...state.logs, log] };
|
||||
}),
|
||||
clearLogs: () => set({ logs: initialLogs }),
|
||||
}),
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { StatusMessage } from "#/types/message";
|
||||
|
||||
const initialStatusMessage: StatusMessage = {
|
||||
status_update: true,
|
||||
type: "info",
|
||||
id: "",
|
||||
message: "",
|
||||
};
|
||||
|
||||
interface StatusState {
|
||||
curStatusMessage: StatusMessage;
|
||||
setCurStatusMessage: (message: StatusMessage) => void;
|
||||
}
|
||||
|
||||
export const useStatusStore = create<StatusState>((set) => ({
|
||||
curStatusMessage: initialStatusMessage,
|
||||
setCurStatusMessage: (message: StatusMessage) =>
|
||||
set({ curStatusMessage: message }),
|
||||
}));
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* @deprecated This type is deprecated and will be removed in a future version.
|
||||
* Use agent-server event types instead.
|
||||
*/
|
||||
export interface ActionMessage {
|
||||
id: number;
|
||||
|
||||
// Either 'agent' or 'user'
|
||||
source: "agent" | "user";
|
||||
|
||||
// The action to be taken
|
||||
action: string;
|
||||
|
||||
// The arguments for the action
|
||||
args: Record<string, string>;
|
||||
|
||||
// A friendly message that can be put in the chat log
|
||||
message: string;
|
||||
|
||||
// The timestamp of the message
|
||||
timestamp: string;
|
||||
|
||||
// LLM metrics information
|
||||
llm_metrics?: {
|
||||
accumulated_cost: number;
|
||||
max_budget_per_task: number | null;
|
||||
accumulated_token_usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
context_window: number;
|
||||
per_turn_token: number;
|
||||
};
|
||||
};
|
||||
|
||||
// Tool call metadata
|
||||
tool_call_metadata?: {
|
||||
model_response?: {
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ObservationMessage {
|
||||
// The type of observation
|
||||
observation: string;
|
||||
|
||||
id: number;
|
||||
cause: number;
|
||||
|
||||
// The observed data
|
||||
content: string;
|
||||
|
||||
extras: {
|
||||
metadata: Record<string, unknown>;
|
||||
error_id: string;
|
||||
[key: string]: string | Record<string, unknown>;
|
||||
};
|
||||
|
||||
// A friendly message that can be put in the chat log
|
||||
message: string;
|
||||
|
||||
// The timestamp of the message
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface StatusMessage {
|
||||
status_update: true;
|
||||
type: string;
|
||||
id?: string;
|
||||
message: string;
|
||||
conversation_title?: string;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
enum ObservationType {
|
||||
// The contents of a file
|
||||
READ = "read",
|
||||
|
||||
// The diff of a file edit
|
||||
EDIT = "edit",
|
||||
|
||||
// The HTML contents of a URL
|
||||
BROWSE = "browse",
|
||||
|
||||
// Interactive browsing
|
||||
BROWSE_INTERACTIVE = "browse_interactive",
|
||||
|
||||
// The output of a command
|
||||
RUN = "run",
|
||||
|
||||
// The output of an IPython command
|
||||
RUN_IPYTHON = "run_ipython",
|
||||
|
||||
// A message from the user
|
||||
CHAT = "chat",
|
||||
|
||||
// Agent state has changed
|
||||
AGENT_STATE_CHANGED = "agent_state_changed",
|
||||
|
||||
// Delegate result
|
||||
DELEGATE = "delegate",
|
||||
|
||||
// A response to the agent's thought (usually a static message)
|
||||
THINK = "think",
|
||||
|
||||
// An observation that shows agent's context extension
|
||||
RECALL = "recall",
|
||||
|
||||
// A MCP tool call observation
|
||||
MCP = "mcp",
|
||||
|
||||
// An error observation
|
||||
ERROR = "error",
|
||||
|
||||
// A no-op observation
|
||||
NULL = "null",
|
||||
|
||||
// Result of a task tracking operation
|
||||
TASK_TRACKING = "task_tracking",
|
||||
}
|
||||
|
||||
export default ObservationType;
|
||||
@@ -1,77 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
const cardTitleVariants = cva("flex items-center", {
|
||||
variants: {
|
||||
gap: {
|
||||
default: "gap-[10px]",
|
||||
},
|
||||
textSize: {
|
||||
default: "text-base",
|
||||
},
|
||||
fontWeight: {
|
||||
default: "font-medium",
|
||||
},
|
||||
textColor: {
|
||||
default: "text-white",
|
||||
},
|
||||
lineHeight: {
|
||||
default: "leading-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
gap: "default",
|
||||
textSize: "default",
|
||||
fontWeight: "default",
|
||||
textColor: "default",
|
||||
lineHeight: "default",
|
||||
},
|
||||
});
|
||||
|
||||
interface CardTitleProps extends VariantProps<typeof cardTitleVariants> {
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
icon,
|
||||
children,
|
||||
className = "",
|
||||
gap,
|
||||
textSize,
|
||||
fontWeight,
|
||||
textColor,
|
||||
lineHeight,
|
||||
}: CardTitleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
cardTitleVariants({
|
||||
gap,
|
||||
textSize,
|
||||
fontWeight,
|
||||
textColor,
|
||||
lineHeight,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span
|
||||
className={cn(
|
||||
cardTitleVariants({
|
||||
lineHeight,
|
||||
textSize,
|
||||
fontWeight,
|
||||
textColor,
|
||||
}),
|
||||
"flex items-center",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
const cardVariants = cva("flex", {
|
||||
variants: {
|
||||
theme: {
|
||||
default:
|
||||
"relative bg-[var(--oh-surface)] border border-[var(--oh-border)] rounded-xl",
|
||||
outlined:
|
||||
"relative bg-transparent border border-[var(--oh-border)] rounded-xl",
|
||||
dark: "relative bg-black border border-[var(--oh-border-subtle)] rounded-2xl",
|
||||
},
|
||||
hover: {
|
||||
none: "",
|
||||
elevated: [
|
||||
"transition-all duration-200",
|
||||
"hover:bg-[linear-gradient(180deg,var(--cool-grey-950)_0%,var(--cool-grey-975)_100%)]",
|
||||
"hover:border-t-[color-mix(in srgb, var(--cool-grey-925) 80%, transparent)]",
|
||||
"hover:shadow-[0px_4px_6px_-4px_rgba(0, 0, 0, 0.1),0px_10px_15px_-3px_rgba(0, 0, 0, 0.1)]",
|
||||
"before:absolute before:inset-0 before:rounded-2xl before:opacity-0 before:transition-opacity before:duration-200",
|
||||
"before:bg-[radial-gradient(98.4%_116.11%_at_50%_0%,rgba(255,255,255,0.08)_0%,rgba(0,0,0,0)_70%)]",
|
||||
"hover:before:opacity-100",
|
||||
"before:pointer-events-none",
|
||||
].join(" "),
|
||||
},
|
||||
gradient: {
|
||||
none: "",
|
||||
standard: [
|
||||
"bg-[color-mix(in srgb, var(--cool-grey-975) 50%, transparent)] border-t-[color-mix(in srgb, var(--cool-grey-925) 60%, transparent)]",
|
||||
"shadow-[0px_4px_6px_-4px_rgba(0, 0, 0, 0.1),0px_10px_15px_-3px_rgba(0, 0, 0, 0.1)]",
|
||||
"before:absolute before:inset-0 before:rounded-2xl before:pointer-events-none",
|
||||
"before:bg-[radial-gradient(144.32%_106.6%_at_50%_0%,rgba(255,255,255,0.14)_0%,rgba(0,0,0,0)_55%)]",
|
||||
].join(" "),
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
theme: "default",
|
||||
hover: "none",
|
||||
gradient: "none",
|
||||
},
|
||||
});
|
||||
|
||||
interface CardProps extends VariantProps<typeof cardVariants> {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
theme,
|
||||
hover,
|
||||
gradient,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className={cn(cardVariants({ theme, hover, gradient }), className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ContextMenuIconTextProps {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
rightIcon?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuIconText({
|
||||
icon,
|
||||
text,
|
||||
rightIcon,
|
||||
className,
|
||||
}: ContextMenuIconTextProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 hover:bg-[var(--oh-interactive-hover)] rounded",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
{text}
|
||||
</div>
|
||||
{rightIcon && <div className="flex items-center">{rightIcon}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { SuggestedTask, SuggestedTaskGroup } from "#/utils/types";
|
||||
|
||||
/**
|
||||
* Groups suggested tasks by their repository.
|
||||
* @param tasks Array of suggested tasks
|
||||
* @returns Array of suggested task groups
|
||||
*/
|
||||
export function groupSuggestedTasks(
|
||||
tasks: SuggestedTask[],
|
||||
): SuggestedTaskGroup[] {
|
||||
const groupsMap: Record<string, SuggestedTaskGroup> = {};
|
||||
|
||||
for (const task of tasks) {
|
||||
const groupKey = `${task.repo}`;
|
||||
|
||||
if (!groupsMap[groupKey]) {
|
||||
groupsMap[groupKey] = {
|
||||
title: groupKey,
|
||||
tasks: [],
|
||||
};
|
||||
}
|
||||
|
||||
groupsMap[groupKey].tasks.push(task);
|
||||
}
|
||||
|
||||
return Object.values(groupsMap);
|
||||
}
|
||||
Reference in New Issue
Block a user