chore: remove unreachable frontend modules and their tests (#16606)

This commit is contained in:
Vasco Schiavo
2026-08-14 15:46:35 +00:00
committed by GitHub
parent 86472bfbdd
commit f6097bda53
58 changed files with 0 additions and 3489 deletions
@@ -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");
});
});
-83
View File
@@ -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();
});
});
@@ -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&param2=value2&param3=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);
});
});
-105
View File
@@ -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();
});
});
-89
View File
@@ -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();
});
});
-24
View File
@@ -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",
);
});
-29
View File
@@ -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");
});
});
-59
View File
@@ -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);
});
-5
View File
@@ -1,5 +0,0 @@
export * from "./types";
export * from "./storage";
export * from "./default-backend";
export * from "./active-store";
export * from "./auth";
-36
View File
@@ -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";
@@ -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>
);
}
-108
View File
@@ -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,
});
-15
View File
@@ -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]);
};
-2
View File
@@ -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,
-101
View File
@@ -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
});
}),
];
-89
View File
@@ -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 }),
),
];
-7
View File
@@ -1,7 +0,0 @@
import { redirect } from "react-router";
export const clientLoader = () => redirect("/conversations");
export default function IndexRedirect() {
return null;
}
-87
View File
@@ -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);
}
}
-92
View File
@@ -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;
}
}
}
-75
View File
@@ -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 }),
}),
);
-20
View File
@@ -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 }),
}));
-78
View File
@@ -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;
}
-48
View File
@@ -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;
-77
View File
@@ -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>
);
}
-66
View File
@@ -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>
);
}
-30
View File
@@ -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>
);
}
-27
View File
@@ -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);
}