mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat: Allow attaching/changing repository for existing conversations (#12671)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com> Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { GitControlBarRepoButton } from "#/components/features/chat/git-control-bar-repo-button";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock GitProviderIcon
|
||||
vi.mock("#/components/shared/git-provider-icon", () => ({
|
||||
GitProviderIcon: ({ gitProvider }: { gitProvider: string }) => (
|
||||
<span data-testid="git-provider-icon">{gitProvider}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock GitExternalLinkIcon
|
||||
vi.mock(
|
||||
"#/components/features/chat/git-external-link-icon",
|
||||
() => ({
|
||||
GitExternalLinkIcon: () => (
|
||||
<span data-testid="git-external-link-icon">external</span>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock RepoForkedIcon
|
||||
vi.mock("#/icons/repo-forked.svg?react", () => ({
|
||||
default: () => <span data-testid="repo-forked-icon">forked</span>,
|
||||
}));
|
||||
|
||||
// Mock constructRepositoryUrl
|
||||
vi.mock("#/utils/utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("#/utils/utils")>();
|
||||
return {
|
||||
...actual,
|
||||
constructRepositoryUrl: (provider: string, repo: string) =>
|
||||
`https://${provider}.com/${repo}`,
|
||||
};
|
||||
});
|
||||
|
||||
describe("GitControlBarRepoButton", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("when repository is connected", () => {
|
||||
it("should render as a link with repository name", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository="owner/repo"
|
||||
gitProvider="github"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "https://github.com/owner/repo");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(screen.getByText("owner/repo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show git provider icon and external link icon", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository="owner/repo"
|
||||
gitProvider="github"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("git-provider-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("git-external-link-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show repo forked icon", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository="owner/repo"
|
||||
gitProvider="github"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("repo-forked-icon"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when no repository is connected", () => {
|
||||
it("should render as a button with 'No Repo Connected' text", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("COMMON$NO_REPO_CONNECTED"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show repo forked icon instead of provider icon", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("repo-forked-icon")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("git-provider-icon"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show external link icon", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("git-external-link-icon"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
const handleClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should be disabled when disabled prop is true", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
disabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveClass("cursor-not-allowed");
|
||||
});
|
||||
|
||||
it("should be clickable when disabled prop is false", () => {
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
disabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveClass("cursor-pointer");
|
||||
});
|
||||
|
||||
it("should not call onClick when disabled", async () => {
|
||||
const handleClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={null}
|
||||
gitProvider={null}
|
||||
onClick={handleClick}
|
||||
disabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("GitControlBar clone prompt format", () => {
|
||||
// Helper function that mirrors the logic in git-control-bar.tsx
|
||||
const generateClonePrompt = (
|
||||
fullName: string,
|
||||
gitProvider: string,
|
||||
branchName: string,
|
||||
) => {
|
||||
const providerName =
|
||||
gitProvider.charAt(0).toUpperCase() + gitProvider.slice(1);
|
||||
return `Clone ${fullName} from ${providerName} and checkout branch ${branchName}.`;
|
||||
};
|
||||
|
||||
it("should include GitHub in clone prompt for github provider", () => {
|
||||
const prompt = generateClonePrompt("user/repo", "github", "main");
|
||||
expect(prompt).toBe("Clone user/repo from Github and checkout branch main.");
|
||||
});
|
||||
|
||||
it("should include GitLab in clone prompt for gitlab provider", () => {
|
||||
const prompt = generateClonePrompt("group/project", "gitlab", "develop");
|
||||
expect(prompt).toBe(
|
||||
"Clone group/project from Gitlab and checkout branch develop.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle different branch names", () => {
|
||||
const prompt = generateClonePrompt(
|
||||
"hieptl.developer-group/hieptl.developer-project",
|
||||
"gitlab",
|
||||
"add-batman-microagent",
|
||||
);
|
||||
expect(prompt).toBe(
|
||||
"Clone hieptl.developer-group/hieptl.developer-project from Gitlab and checkout branch add-batman-microagent.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should capitalize first letter of provider name", () => {
|
||||
const githubPrompt = generateClonePrompt("a/b", "github", "main");
|
||||
const gitlabPrompt = generateClonePrompt("a/b", "gitlab", "main");
|
||||
|
||||
expect(githubPrompt).toContain("from Github");
|
||||
expect(gitlabPrompt).toContain("from Gitlab");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { OpenRepositoryModal } from "#/components/features/chat/open-repository-modal";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useUserProviders - default to single provider (no dropdown shown)
|
||||
const mockProviders = vi.hoisted(() => ({
|
||||
current: ["github"] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => ({
|
||||
providers: mockProviders.current,
|
||||
isLoadingSettings: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock GitProviderDropdown
|
||||
vi.mock(
|
||||
"#/components/features/home/git-provider-dropdown/git-provider-dropdown",
|
||||
() => ({
|
||||
GitProviderDropdown: ({
|
||||
providers,
|
||||
onChange,
|
||||
}: {
|
||||
providers: string[];
|
||||
onChange: (provider: string | null) => void;
|
||||
}) => (
|
||||
<div data-testid="git-provider-dropdown">
|
||||
{providers.map((p: string) => (
|
||||
<button
|
||||
key={p}
|
||||
data-testid={`provider-${p}`}
|
||||
onClick={() => onChange(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock GitRepoDropdown
|
||||
vi.mock(
|
||||
"#/components/features/home/git-repo-dropdown/git-repo-dropdown",
|
||||
() => ({
|
||||
GitRepoDropdown: ({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (repo?: {
|
||||
id: number;
|
||||
full_name: string;
|
||||
git_provider: string;
|
||||
main_branch: string;
|
||||
}) => void;
|
||||
}) => (
|
||||
<button
|
||||
data-testid="git-repo-dropdown"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
id: 1,
|
||||
full_name: "owner/repo",
|
||||
git_provider: "github",
|
||||
main_branch: "main",
|
||||
})
|
||||
}
|
||||
>
|
||||
Mock Repo Dropdown
|
||||
</button>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock GitBranchDropdown
|
||||
vi.mock(
|
||||
"#/components/features/home/git-branch-dropdown/git-branch-dropdown",
|
||||
() => ({
|
||||
GitBranchDropdown: ({
|
||||
onBranchSelect,
|
||||
disabled,
|
||||
}: {
|
||||
onBranchSelect: (branch: { name: string } | null) => void;
|
||||
disabled: boolean;
|
||||
}) => (
|
||||
<button
|
||||
data-testid="git-branch-dropdown"
|
||||
disabled={disabled}
|
||||
onClick={() => onBranchSelect({ name: "main" })}
|
||||
>
|
||||
Mock Branch Dropdown
|
||||
</button>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock RepoForkedIcon
|
||||
vi.mock("#/icons/repo-forked.svg?react", () => ({
|
||||
default: () => <div data-testid="repo-forked-icon" />,
|
||||
}));
|
||||
|
||||
describe("OpenRepositoryModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnLaunch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviders.current = ["github"];
|
||||
});
|
||||
|
||||
it("should not render when isOpen is false", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={false}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText("CONVERSATION$OPEN_REPOSITORY"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render modal with title and description when open", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("CONVERSATION$OPEN_REPOSITORY"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("CONVERSATION$SELECT_OR_INSERT_LINK"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("repo-forked-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Launch and Cancel buttons", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("BUTTON$LAUNCH")).toBeInTheDocument();
|
||||
expect(screen.getByText("BUTTON$CANCEL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable Launch button when no repository or branch is selected", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call onClose and reset state when Cancel is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("BUTTON$CANCEL"));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should enable Launch button after selecting repository and branch", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select a repository
|
||||
await user.click(screen.getByTestId("git-repo-dropdown"));
|
||||
|
||||
// Select a branch
|
||||
await user.click(screen.getByTestId("git-branch-dropdown"));
|
||||
|
||||
const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call onLaunch with selected repository and branch, then close", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select repository and branch
|
||||
await user.click(screen.getByTestId("git-repo-dropdown"));
|
||||
await user.click(screen.getByTestId("git-branch-dropdown"));
|
||||
|
||||
// Click Launch
|
||||
await user.click(screen.getByText("BUTTON$LAUNCH"));
|
||||
|
||||
expect(mockOnLaunch).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 1,
|
||||
full_name: "owner/repo",
|
||||
git_provider: "github",
|
||||
main_branch: "main",
|
||||
},
|
||||
{ name: "main" },
|
||||
);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not call onLaunch when Launch is clicked without selections", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Force click the launch button even though it's disabled
|
||||
const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button")!;
|
||||
await user.click(launchButton);
|
||||
|
||||
expect(mockOnLaunch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should reset branch selection when repository changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select repository and branch
|
||||
await user.click(screen.getByTestId("git-repo-dropdown"));
|
||||
await user.click(screen.getByTestId("git-branch-dropdown"));
|
||||
|
||||
// Launch button should be enabled
|
||||
let launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).not.toBeDisabled();
|
||||
|
||||
// Select a new repository (resets branch)
|
||||
await user.click(screen.getByTestId("git-repo-dropdown"));
|
||||
|
||||
// Launch button should be disabled again (branch was reset)
|
||||
launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should use small modal width", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// ModalBody with width="small" renders w-[384px]
|
||||
const modalBody = screen
|
||||
.getByText("CONVERSATION$OPEN_REPOSITORY")
|
||||
.closest(".bg-base-secondary");
|
||||
expect(modalBody).toHaveClass("w-[384px]");
|
||||
});
|
||||
|
||||
it("should override default gap with !gap-4 for tighter spacing", () => {
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
const modalBody = screen
|
||||
.getByText("CONVERSATION$OPEN_REPOSITORY")
|
||||
.closest(".bg-base-secondary");
|
||||
expect(modalBody).toHaveClass("!gap-4");
|
||||
});
|
||||
|
||||
describe("provider switching", () => {
|
||||
it("should not show provider dropdown when only one provider exists", () => {
|
||||
mockProviders.current = ["github"];
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("git-provider-dropdown"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show provider dropdown when multiple providers exist", () => {
|
||||
mockProviders.current = ["github", "gitlab"];
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-github")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-gitlab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should reset repository and branch when provider changes", async () => {
|
||||
mockProviders.current = ["github", "gitlab"];
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<OpenRepositoryModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onLaunch={mockOnLaunch}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select repo and branch
|
||||
await user.click(screen.getByTestId("git-repo-dropdown"));
|
||||
await user.click(screen.getByTestId("git-branch-dropdown"));
|
||||
|
||||
// Launch should be enabled
|
||||
let launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).not.toBeDisabled();
|
||||
|
||||
// Switch provider — should reset selections
|
||||
await user.click(screen.getByTestId("provider-gitlab"));
|
||||
|
||||
// Launch should be disabled (repo and branch reset)
|
||||
launchButton = screen.getByText("BUTTON$LAUNCH").closest("button");
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useUpdateConversationRepository } from "#/hooks/mutation/use-update-conversation-repository";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
// Mock the V1ConversationService
|
||||
vi.mock("#/api/conversation-service/v1-conversation-service.api", () => ({
|
||||
default: {
|
||||
updateConversationRepository: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock toast handlers
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displaySuccessToast: vi.fn(),
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("useUpdateConversationRepository", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should call updateConversationRepository with correct parameters", async () => {
|
||||
const mockResponse = {
|
||||
id: "test-conversation-id",
|
||||
selected_repository: "owner/repo",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
};
|
||||
|
||||
vi.mocked(V1ConversationService.updateConversationRepository).mockResolvedValue(
|
||||
mockResponse as any,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useUpdateConversationRepository(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
conversationId: "test-conversation-id",
|
||||
repository: "owner/repo",
|
||||
branch: "main",
|
||||
gitProvider: "github",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(V1ConversationService.updateConversationRepository).toHaveBeenCalledWith(
|
||||
"test-conversation-id",
|
||||
"owner/repo",
|
||||
"main",
|
||||
"github",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle repository removal (null values)", async () => {
|
||||
const mockResponse = {
|
||||
id: "test-conversation-id",
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
};
|
||||
|
||||
vi.mocked(V1ConversationService.updateConversationRepository).mockResolvedValue(
|
||||
mockResponse as any,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useUpdateConversationRepository(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
conversationId: "test-conversation-id",
|
||||
repository: null,
|
||||
branch: null,
|
||||
gitProvider: null,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(V1ConversationService.updateConversationRepository).toHaveBeenCalledWith(
|
||||
"test-conversation-id",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
vi.mocked(V1ConversationService.updateConversationRepository).mockRejectedValue(
|
||||
new Error("Failed to update repository"),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useUpdateConversationRepository(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
conversationId: "test-conversation-id",
|
||||
repository: "owner/repo",
|
||||
branch: "main",
|
||||
gitProvider: "github",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -319,6 +319,39 @@ class V1ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a V1 conversation's repository settings
|
||||
* @param conversationId The conversation ID
|
||||
* @param repository The repository to attach (e.g., "owner/repo") or null to remove
|
||||
* @param branch The branch to use (optional)
|
||||
* @param gitProvider The git provider (e.g., "github", "gitlab")
|
||||
* @returns Updated conversation info
|
||||
*/
|
||||
static async updateConversationRepository(
|
||||
conversationId: string,
|
||||
repository: string | null,
|
||||
branch?: string | null,
|
||||
gitProvider?: string | null,
|
||||
): Promise<V1AppConversation> {
|
||||
const payload: Record<string, string | null | undefined> = {};
|
||||
|
||||
if (repository !== undefined) {
|
||||
payload.selected_repository = repository;
|
||||
}
|
||||
if (branch !== undefined) {
|
||||
payload.selected_branch = branch;
|
||||
}
|
||||
if (gitProvider !== undefined) {
|
||||
payload.git_provider = gitProvider;
|
||||
}
|
||||
|
||||
const { data } = await openHands.patch<V1AppConversation>(
|
||||
`/api/v1/app-conversations/${conversationId}`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from a specific conversation's sandbox workspace
|
||||
* @param conversationId The conversation ID
|
||||
|
||||
@@ -9,11 +9,15 @@ import RepoForkedIcon from "#/icons/repo-forked.svg?react";
|
||||
interface GitControlBarRepoButtonProps {
|
||||
selectedRepository: string | null | undefined;
|
||||
gitProvider: Provider | null | undefined;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function GitControlBarRepoButton({
|
||||
selectedRepository,
|
||||
gitProvider,
|
||||
onClick,
|
||||
disabled,
|
||||
}: GitControlBarRepoButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -27,27 +31,49 @@ export function GitControlBarRepoButton({
|
||||
? selectedRepository
|
||||
: t(I18nKey.COMMON$NO_REPO_CONNECTED);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={hasRepository ? repositoryUrl : undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] flex-1 truncate relative",
|
||||
hasRepository
|
||||
? "border border-[#525252] bg-transparent hover:border-[#454545] cursor-pointer"
|
||||
: "border border-[rgba(71,74,84,0.50)] bg-transparent cursor-not-allowed min-w-[170px]",
|
||||
)}
|
||||
>
|
||||
<div className="w-3 h-3 flex items-center justify-center flex-shrink-0">
|
||||
{hasRepository ? (
|
||||
if (hasRepository) {
|
||||
return (
|
||||
<a
|
||||
href={repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] flex-1 truncate relative",
|
||||
"border border-[#525252] bg-transparent hover:border-[#454545] cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<div className="w-3 h-3 flex items-center justify-center flex-shrink-0">
|
||||
<GitProviderIcon
|
||||
gitProvider={gitProvider as Provider}
|
||||
className="w-3 h-3 inline-flex"
|
||||
/>
|
||||
) : (
|
||||
<RepoForkedIcon width={12} height={12} color="white" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="font-normal text-white text-sm leading-5 truncate flex-1 min-w-0"
|
||||
title={buttonText}
|
||||
>
|
||||
{buttonText}
|
||||
</div>
|
||||
<GitExternalLinkIcon />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] flex-1 truncate relative min-w-[170px]",
|
||||
"border border-[rgba(71,74,84,0.50)] bg-transparent",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "cursor-pointer hover:border-[#454545]",
|
||||
)}
|
||||
>
|
||||
<div className="w-3 h-3 flex items-center justify-center flex-shrink-0">
|
||||
<RepoForkedIcon width={12} height={12} color="white" />
|
||||
</div>
|
||||
<div
|
||||
className="font-normal text-white text-sm leading-5 truncate flex-1 min-w-0"
|
||||
@@ -55,7 +81,6 @@ export function GitControlBarRepoButton({
|
||||
>
|
||||
{buttonText}
|
||||
</div>
|
||||
{hasRepository && <GitExternalLinkIcon />}
|
||||
</a>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router";
|
||||
import { GitControlBarRepoButton } from "./git-control-bar-repo-button";
|
||||
import { GitControlBarBranchButton } from "./git-control-bar-branch-button";
|
||||
import { GitControlBarPullButton } from "./git-control-bar-pull-button";
|
||||
@@ -7,9 +9,16 @@ import { GitControlBarPrButton } from "./git-control-bar-pr-button";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { useUpdateConversationRepository } from "#/hooks/mutation/use-update-conversation-repository";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { Branch, GitRepository } from "#/types/git";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { GitControlBarTooltipWrapper } from "./git-control-bar-tooltip-wrapper";
|
||||
import { OpenRepositoryModal } from "./open-repository-modal";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useHomeStore } from "#/stores/home-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
|
||||
interface GitControlBarProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -17,10 +26,24 @@ interface GitControlBarProps {
|
||||
|
||||
export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
const [isOpenRepoModalOpen, setIsOpenRepoModalOpen] = useState(false);
|
||||
const { addRecentRepository } = useHomeStore();
|
||||
const { setOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { repositoryInfo } = useTaskPolling();
|
||||
const webSocketStatus = useUnifiedWebSocketStatus();
|
||||
const webSocketStatusRef = useRef(webSocketStatus);
|
||||
useEffect(() => {
|
||||
webSocketStatusRef.current = webSocketStatus;
|
||||
}, [webSocketStatus]);
|
||||
const { send } = useSendMessage();
|
||||
const sendRef = useRef(send);
|
||||
useEffect(() => {
|
||||
sendRef.current = send;
|
||||
}, [send]);
|
||||
const { mutate: updateRepository } = useUpdateConversationRepository();
|
||||
|
||||
// Priority: conversation data > task data
|
||||
// This ensures we show repository info immediately from task, then transition to conversation data
|
||||
@@ -36,19 +59,67 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
|
||||
// Enable buttons only when conversation exists and WS is connected
|
||||
const isConversationReady = !!conversation && webSocketStatus === "CONNECTED";
|
||||
|
||||
const handleLaunchRepository = (
|
||||
repository: GitRepository,
|
||||
branch: Branch,
|
||||
) => {
|
||||
if (!conversationId) return;
|
||||
|
||||
// Persist to recent repositories list (matches home page behavior)
|
||||
addRecentRepository(repository);
|
||||
|
||||
// Note: We update repository metadata first, then send clone command.
|
||||
// The clone command is sent to the agent via WebSocket (fire-and-forget).
|
||||
// If cloning fails, the agent will report the error in the chat,
|
||||
// and the user can retry or change the repository.
|
||||
// This is a trade-off: immediate UI feedback vs. strict atomicity.
|
||||
updateRepository(
|
||||
{
|
||||
conversationId,
|
||||
repository: repository.full_name,
|
||||
branch: branch.name,
|
||||
gitProvider: repository.git_provider,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Use ref to read the latest WebSocket status (avoids stale closure)
|
||||
if (webSocketStatusRef.current !== "CONNECTED") {
|
||||
displayErrorToast(
|
||||
t(I18nKey.CONVERSATION$CLONE_COMMAND_FAILED_DISCONNECTED),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send clone command to agent after metadata is updated
|
||||
// Use ref to always call the latest send function (avoids stale closure
|
||||
// where V1 sendMessage holds a reference to a now-closed WebSocket)
|
||||
// Include git provider in prompt so agent clones from correct source
|
||||
const providerName =
|
||||
repository.git_provider.charAt(0).toUpperCase() +
|
||||
repository.git_provider.slice(1);
|
||||
const clonePrompt = `Clone ${repository.full_name} from ${providerName} and checkout branch ${branch.name}.`;
|
||||
setOptimisticUserMessage(clonePrompt);
|
||||
sendRef.current({
|
||||
action: "message",
|
||||
args: {
|
||||
content: clonePrompt,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row gap-2.5 items-center overflow-x-auto flex-wrap md:flex-nowrap relative scrollbar-hide">
|
||||
<GitControlBarTooltipWrapper
|
||||
tooltipMessage={t(I18nKey.COMMON$GIT_TOOLS_DISABLED_CONTENT)}
|
||||
testId="git-control-bar-repo-button-tooltip"
|
||||
shouldShowTooltip={!hasRepository}
|
||||
>
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={selectedRepository}
|
||||
gitProvider={gitProvider}
|
||||
/>
|
||||
</GitControlBarTooltipWrapper>
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={selectedRepository}
|
||||
gitProvider={gitProvider}
|
||||
onClick={() => setIsOpenRepoModalOpen(true)}
|
||||
disabled={!isConversationReady}
|
||||
/>
|
||||
|
||||
<GitControlBarTooltipWrapper
|
||||
tooltipMessage={t(I18nKey.COMMON$GIT_TOOLS_DISABLED_CONTENT)}
|
||||
@@ -103,6 +174,13 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<OpenRepositoryModal
|
||||
isOpen={isOpenRepoModalOpen}
|
||||
onClose={() => setIsOpenRepoModalOpen(false)}
|
||||
onLaunch={handleLaunchRepository}
|
||||
defaultProvider={gitProvider}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
170
frontend/src/components/features/chat/open-repository-modal.tsx
Normal file
170
frontend/src/components/features/chat/open-repository-modal.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { Branch, GitRepository } from "#/types/git";
|
||||
import { GitRepoDropdown } from "#/components/features/home/git-repo-dropdown/git-repo-dropdown";
|
||||
import { GitBranchDropdown } from "#/components/features/home/git-branch-dropdown/git-branch-dropdown";
|
||||
import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown/git-provider-dropdown";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import RepoForkedIcon from "#/icons/repo-forked.svg?react";
|
||||
|
||||
interface OpenRepositoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onLaunch: (repository: GitRepository, branch: Branch) => void;
|
||||
defaultProvider?: Provider;
|
||||
}
|
||||
|
||||
export function OpenRepositoryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onLaunch,
|
||||
defaultProvider = "github",
|
||||
}: OpenRepositoryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const [selectedProvider, setSelectedProvider] = useState<Provider | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedRepository, setSelectedRepository] =
|
||||
useState<GitRepository | null>(null);
|
||||
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
|
||||
|
||||
// Auto-select provider: single provider auto-selects, multiple uses defaultProvider if available
|
||||
useEffect(() => {
|
||||
if (providers.length === 1 && !selectedProvider) {
|
||||
setSelectedProvider(providers[0]);
|
||||
} else if (providers.length > 1 && !selectedProvider && defaultProvider) {
|
||||
if (providers.includes(defaultProvider)) {
|
||||
setSelectedProvider(defaultProvider);
|
||||
}
|
||||
}
|
||||
}, [providers, selectedProvider, defaultProvider]);
|
||||
|
||||
const handleProviderChange = useCallback(
|
||||
(provider: Provider | null) => {
|
||||
if (provider === selectedProvider) return;
|
||||
setSelectedProvider(provider);
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
},
|
||||
[selectedProvider],
|
||||
);
|
||||
|
||||
const handleRepositoryChange = useCallback((repository?: GitRepository) => {
|
||||
if (repository) {
|
||||
setSelectedRepository(repository);
|
||||
setSelectedBranch(null);
|
||||
} else {
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBranchSelect = useCallback((branch: Branch | null) => {
|
||||
setSelectedBranch(branch);
|
||||
}, []);
|
||||
|
||||
const handleLaunch = () => {
|
||||
if (!selectedRepository || !selectedBranch) return;
|
||||
|
||||
onLaunch(selectedRepository, selectedBranch);
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedProvider(null);
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const activeProvider =
|
||||
selectedRepository?.git_provider || selectedProvider || defaultProvider;
|
||||
const canLaunch = !!selectedRepository && !!selectedBranch;
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={handleClose}>
|
||||
<ModalBody
|
||||
width="small"
|
||||
className="items-start border border-tertiary !gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<RepoForkedIcon width={24} height={24} />
|
||||
<BaseModalTitle title={t(I18nKey.CONVERSATION$OPEN_REPOSITORY)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-white font-normal leading-[22px]">
|
||||
{t(I18nKey.CONVERSATION$SELECT_OR_INSERT_LINK)}
|
||||
</span>
|
||||
{providers.length > 1 && (
|
||||
<GitProviderDropdown
|
||||
providers={providers}
|
||||
value={selectedProvider}
|
||||
placeholder="Select Provider"
|
||||
onChange={handleProviderChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[10px] w-full">
|
||||
<GitRepoDropdown
|
||||
provider={activeProvider}
|
||||
value={selectedRepository?.id || null}
|
||||
repositoryName={selectedRepository?.full_name || null}
|
||||
onChange={handleRepositoryChange}
|
||||
placeholder="Search repositories..."
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<GitBranchDropdown
|
||||
repository={selectedRepository?.full_name || null}
|
||||
provider={activeProvider}
|
||||
selectedBranch={selectedBranch}
|
||||
onBranchSelect={handleBranchSelect}
|
||||
defaultBranch={selectedRepository?.main_branch || null}
|
||||
placeholder="Select branch..."
|
||||
disabled={!selectedRepository}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col gap-2 w-full"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleLaunch}
|
||||
className="w-full"
|
||||
isDisabled={!canLaunch}
|
||||
>
|
||||
{t(I18nKey.BUTTON$LAUNCH)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleClose}
|
||||
className="w-full"
|
||||
>
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displaySuccessToast,
|
||||
displayErrorToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
interface UpdateRepositoryVariables {
|
||||
conversationId: string;
|
||||
repository: string | null;
|
||||
branch?: string | null;
|
||||
gitProvider?: Provider | null;
|
||||
}
|
||||
|
||||
export const useUpdateConversationRepository = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: UpdateRepositoryVariables) =>
|
||||
V1ConversationService.updateConversationRepository(
|
||||
variables.conversationId,
|
||||
variables.repository,
|
||||
variables.branch,
|
||||
variables.gitProvider,
|
||||
),
|
||||
onMutate: async (variables) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["user", "conversation", variables.conversationId],
|
||||
});
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousConversation = queryClient.getQueryData([
|
||||
"user",
|
||||
"conversation",
|
||||
variables.conversationId,
|
||||
]);
|
||||
|
||||
// Optimistically update the conversation
|
||||
queryClient.setQueryData(
|
||||
["user", "conversation", variables.conversationId],
|
||||
(old: unknown) =>
|
||||
old && typeof old === "object"
|
||||
? {
|
||||
...old,
|
||||
selected_repository: variables.repository,
|
||||
selected_branch: variables.branch,
|
||||
git_provider: variables.gitProvider,
|
||||
}
|
||||
: old,
|
||||
);
|
||||
|
||||
return { previousConversation };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousConversation) {
|
||||
queryClient.setQueryData(
|
||||
["user", "conversation", variables.conversationId],
|
||||
context.previousConversation,
|
||||
);
|
||||
}
|
||||
displayErrorToast(t(I18nKey.CONVERSATION$FAILED_TO_UPDATE_REPOSITORY));
|
||||
},
|
||||
onSuccess: () => {
|
||||
displaySuccessToast(t(I18nKey.CONVERSATION$REPOSITORY_UPDATED));
|
||||
},
|
||||
onSettled: (data, error, variables) => {
|
||||
// Always refetch after error or success
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversation", variables.conversationId],
|
||||
});
|
||||
// Also invalidate the conversations list to update any cached data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { V1MessageContent } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
/**
|
||||
@@ -10,13 +11,19 @@ import { V1MessageContent } from "#/api/conversation-service/v1-conversation-ser
|
||||
* - For V1 conversations: Uses native WebSocket via ConversationWebSocketProvider
|
||||
*/
|
||||
export function useSendMessage() {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { send: v0Send } = useWsClient();
|
||||
|
||||
// Get V1 context (will be null if not in V1 provider)
|
||||
const v1Context = useConversationWebSocket();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
// Check if this is a V1 conversation - match logic in useUnifiedWebSocketStatus
|
||||
// Use both ID prefix and conversation_version to handle cases where conversation
|
||||
// data is temporarily undefined during refetch
|
||||
const isV1Conversation =
|
||||
conversationId.startsWith("task-") ||
|
||||
conversation?.conversation_version === "V1";
|
||||
|
||||
const send = useCallback(
|
||||
async (event: Record<string, unknown>) => {
|
||||
@@ -64,7 +71,7 @@ export function useSendMessage() {
|
||||
v0Send(event);
|
||||
}
|
||||
},
|
||||
[isV1Conversation, v1Context, v0Send],
|
||||
[isV1Conversation, v1Context, v0Send, conversationId],
|
||||
);
|
||||
|
||||
return { send };
|
||||
|
||||
@@ -211,6 +211,7 @@ export enum I18nKey {
|
||||
MODAL$END_SESSION_TITLE = "MODAL$END_SESSION_TITLE",
|
||||
MODAL$END_SESSION_MESSAGE = "MODAL$END_SESSION_MESSAGE",
|
||||
BUTTON$END_SESSION = "BUTTON$END_SESSION",
|
||||
BUTTON$LAUNCH = "BUTTON$LAUNCH",
|
||||
BUTTON$CANCEL = "BUTTON$CANCEL",
|
||||
EXIT_PROJECT$CONFIRM = "EXIT_PROJECT$CONFIRM",
|
||||
EXIT_PROJECT$TITLE = "EXIT_PROJECT$TITLE",
|
||||
@@ -999,6 +1000,14 @@ export enum I18nKey {
|
||||
CONVERSATION$SHARE_PUBLICLY = "CONVERSATION$SHARE_PUBLICLY",
|
||||
CONVERSATION$PUBLIC_SHARING_UPDATED = "CONVERSATION$PUBLIC_SHARING_UPDATED",
|
||||
CONVERSATION$FAILED_TO_UPDATE_PUBLIC_SHARING = "CONVERSATION$FAILED_TO_UPDATE_PUBLIC_SHARING",
|
||||
CONVERSATION$REPOSITORY_UPDATED = "CONVERSATION$REPOSITORY_UPDATED",
|
||||
CONVERSATION$FAILED_TO_UPDATE_REPOSITORY = "CONVERSATION$FAILED_TO_UPDATE_REPOSITORY",
|
||||
CONVERSATION$CLONE_COMMAND_FAILED_DISCONNECTED = "CONVERSATION$CLONE_COMMAND_FAILED_DISCONNECTED",
|
||||
CONVERSATION$CHANGE_REPOSITORY = "CONVERSATION$CHANGE_REPOSITORY",
|
||||
CONVERSATION$ATTACH_REPOSITORY = "CONVERSATION$ATTACH_REPOSITORY",
|
||||
CONVERSATION$OPEN_REPOSITORY = "CONVERSATION$OPEN_REPOSITORY",
|
||||
CONVERSATION$SELECT_OR_INSERT_LINK = "CONVERSATION$SELECT_OR_INSERT_LINK",
|
||||
CONVERSATION$NO_REPO_CONNECTED = "CONVERSATION$NO_REPO_CONNECTED",
|
||||
CONVERSATION$NOT_FOUND = "CONVERSATION$NOT_FOUND",
|
||||
CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE",
|
||||
CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION",
|
||||
|
||||
@@ -3375,6 +3375,22 @@
|
||||
"de": "Sitzung beenden",
|
||||
"uk": "Закінчити сеанс"
|
||||
},
|
||||
"BUTTON$LAUNCH": {
|
||||
"en": "Launch",
|
||||
"ja": "起動",
|
||||
"zh-CN": "启动",
|
||||
"zh-TW": "啟動",
|
||||
"ko-KR": "시작",
|
||||
"no": "Start",
|
||||
"it": "Avvia",
|
||||
"pt": "Iniciar",
|
||||
"es": "Iniciar",
|
||||
"ar": "إطلاق",
|
||||
"fr": "Lancer",
|
||||
"tr": "Başlat",
|
||||
"de": "Starten",
|
||||
"uk": "Запустити"
|
||||
},
|
||||
"BUTTON$CANCEL": {
|
||||
"en": "Cancel",
|
||||
"ja": "キャンセル",
|
||||
@@ -15983,6 +15999,134 @@
|
||||
"de": "Fehler beim Aktualisieren der öffentlichen Freigabe",
|
||||
"uk": "Не вдалося оновити публічний доступ"
|
||||
},
|
||||
"CONVERSATION$REPOSITORY_UPDATED": {
|
||||
"en": "Repository updated successfully",
|
||||
"ja": "リポジトリが正常に更新されました",
|
||||
"zh-CN": "仓库更新成功",
|
||||
"zh-TW": "儲存庫更新成功",
|
||||
"ko-KR": "저장소가 성공적으로 업데이트되었습니다",
|
||||
"no": "Repository oppdatert",
|
||||
"it": "Repository aggiornato con successo",
|
||||
"pt": "Repositório atualizado com sucesso",
|
||||
"es": "Repositorio actualizado correctamente",
|
||||
"ar": "تم تحديث المستودع بنجاح",
|
||||
"fr": "Dépôt mis à jour avec succès",
|
||||
"tr": "Depo başarıyla güncellendi",
|
||||
"de": "Repository erfolgreich aktualisiert",
|
||||
"uk": "Репозиторій успішно оновлено"
|
||||
},
|
||||
"CONVERSATION$FAILED_TO_UPDATE_REPOSITORY": {
|
||||
"en": "Failed to update repository",
|
||||
"ja": "リポジトリの更新に失敗しました",
|
||||
"zh-CN": "更新仓库失败",
|
||||
"zh-TW": "更新儲存庫失敗",
|
||||
"ko-KR": "저장소 업데이트에 실패했습니다",
|
||||
"no": "Kunne ikke oppdatere repository",
|
||||
"it": "Impossibile aggiornare il repository",
|
||||
"pt": "Falha ao atualizar repositório",
|
||||
"es": "Error al actualizar repositorio",
|
||||
"ar": "فشل في تحديث المستودع",
|
||||
"fr": "Échec de la mise à jour du dépôt",
|
||||
"tr": "Depo güncellenemedi",
|
||||
"de": "Fehler beim Aktualisieren des Repositorys",
|
||||
"uk": "Не вдалося оновити репозиторій"
|
||||
},
|
||||
"CONVERSATION$CLONE_COMMAND_FAILED_DISCONNECTED": {
|
||||
"en": "Repository updated but clone command could not be sent. Please reconnect and manually clone.",
|
||||
"ja": "リポジトリは更新されましたが、クローンコマンドを送信できませんでした。再接続して手動でクローンしてください。",
|
||||
"zh-CN": "仓库已更新,但无法发送克隆命令。请重新连接并手动克隆。",
|
||||
"zh-TW": "儲存庫已更新,但無法傳送複製命令。請重新連線並手動複製。",
|
||||
"ko-KR": "저장소가 업데이트되었지만 복제 명령을 보낼 수 없습니다. 다시 연결하고 수동으로 복제하세요.",
|
||||
"no": "Repository oppdatert, men klonekommando kunne ikke sendes. Koble til på nytt og klon manuelt.",
|
||||
"it": "Repository aggiornato ma il comando di clone non può essere inviato. Riconnettiti e clona manualmente.",
|
||||
"pt": "Repositório atualizado, mas o comando de clone não pôde ser enviado. Reconecte e clone manualmente.",
|
||||
"es": "Repositorio actualizado pero no se pudo enviar el comando de clonación. Reconéctese y clone manualmente.",
|
||||
"ar": "تم تحديث المستودع ولكن تعذر إرسال أمر الاستنساخ. يرجى إعادة الاتصال والاستنساخ يدويًا.",
|
||||
"fr": "Dépôt mis à jour mais la commande de clonage n'a pas pu être envoyée. Reconnectez-vous et clonez manuellement.",
|
||||
"tr": "Depo güncellendi ancak klonlama komutu gönderilemedi. Lütfen yeniden bağlanın ve manuel olarak klonlayın.",
|
||||
"de": "Repository aktualisiert, aber Klon-Befehl konnte nicht gesendet werden. Bitte erneut verbinden und manuell klonen.",
|
||||
"uk": "Репозиторій оновлено, але команду клонування не вдалося надіслати. Підключіться знову та клонуйте вручну."
|
||||
},
|
||||
"CONVERSATION$CHANGE_REPOSITORY": {
|
||||
"en": "Change Repository",
|
||||
"ja": "リポジトリを変更",
|
||||
"zh-CN": "更改仓库",
|
||||
"zh-TW": "變更儲存庫",
|
||||
"ko-KR": "저장소 변경",
|
||||
"no": "Endre repository",
|
||||
"it": "Cambia repository",
|
||||
"pt": "Alterar repositório",
|
||||
"es": "Cambiar repositorio",
|
||||
"ar": "تغيير المستودع",
|
||||
"fr": "Changer de dépôt",
|
||||
"tr": "Depoyu değiştir",
|
||||
"de": "Repository ändern",
|
||||
"uk": "Змінити репозиторій"
|
||||
},
|
||||
"CONVERSATION$ATTACH_REPOSITORY": {
|
||||
"en": "Attach Repository",
|
||||
"ja": "リポジトリを添付",
|
||||
"zh-CN": "附加仓库",
|
||||
"zh-TW": "附加儲存庫",
|
||||
"ko-KR": "저장소 연결",
|
||||
"no": "Legg til repository",
|
||||
"it": "Allega repository",
|
||||
"pt": "Anexar repositório",
|
||||
"es": "Adjuntar repositorio",
|
||||
"ar": "إرفاق المستودع",
|
||||
"fr": "Attacher un dépôt",
|
||||
"tr": "Depo ekle",
|
||||
"de": "Repository anhängen",
|
||||
"uk": "Прикріпити репозиторій"
|
||||
},
|
||||
"CONVERSATION$OPEN_REPOSITORY": {
|
||||
"en": "Open Repository",
|
||||
"ja": "リポジトリを開く",
|
||||
"zh-CN": "打开仓库",
|
||||
"zh-TW": "開啟儲存庫",
|
||||
"ko-KR": "저장소 열기",
|
||||
"no": "Åpne repository",
|
||||
"it": "Apri repository",
|
||||
"pt": "Abrir repositório",
|
||||
"es": "Abrir repositorio",
|
||||
"ar": "فتح المستودع",
|
||||
"fr": "Ouvrir le dépôt",
|
||||
"tr": "Depoyu aç",
|
||||
"de": "Repository öffnen",
|
||||
"uk": "Відкрити репозиторій"
|
||||
},
|
||||
"CONVERSATION$SELECT_OR_INSERT_LINK": {
|
||||
"en": "Select or insert a link",
|
||||
"ja": "リンクを選択または挿入",
|
||||
"zh-CN": "选择或插入链接",
|
||||
"zh-TW": "選擇或插入連結",
|
||||
"ko-KR": "링크 선택 또는 삽입",
|
||||
"no": "Velg eller sett inn en lenke",
|
||||
"it": "Seleziona o inserisci un link",
|
||||
"pt": "Selecione ou insira um link",
|
||||
"es": "Selecciona o inserta un enlace",
|
||||
"ar": "حدد أو أدخل رابطًا",
|
||||
"fr": "Sélectionner ou insérer un lien",
|
||||
"tr": "Bir bağlantı seçin veya ekleyin",
|
||||
"de": "Link auswählen oder einfügen",
|
||||
"uk": "Виберіть або вставте посилання"
|
||||
},
|
||||
"CONVERSATION$NO_REPO_CONNECTED": {
|
||||
"en": "No Repo Connected",
|
||||
"ja": "リポジトリ未接続",
|
||||
"zh-CN": "未连接仓库",
|
||||
"zh-TW": "未連接儲存庫",
|
||||
"ko-KR": "저장소 연결 안 됨",
|
||||
"no": "Ingen repository tilkoblet",
|
||||
"it": "Nessun repository collegato",
|
||||
"pt": "Nenhum repositório conectado",
|
||||
"es": "Sin repositorio conectado",
|
||||
"ar": "لا يوجد مستودع متصل",
|
||||
"fr": "Aucun dépôt connecté",
|
||||
"tr": "Bağlı depo yok",
|
||||
"de": "Kein Repository verbunden",
|
||||
"uk": "Репозиторій не підключено"
|
||||
},
|
||||
"CONVERSATION$NOT_FOUND": {
|
||||
"en": "Conversation not found",
|
||||
"ja": "会話が見つかりません",
|
||||
|
||||
@@ -170,7 +170,15 @@ class AppConversationStartRequest(OpenHandsModel):
|
||||
|
||||
|
||||
class AppConversationUpdateRequest(BaseModel):
|
||||
public: bool
|
||||
"""Request model for updating conversation metadata.
|
||||
|
||||
All fields are optional - only provided fields will be updated.
|
||||
"""
|
||||
|
||||
public: bool | None = None
|
||||
selected_repository: str | None = None
|
||||
selected_branch: str | None = None
|
||||
git_provider: ProviderType | None = None
|
||||
|
||||
|
||||
class AppConversationStartTaskStatus(Enum):
|
||||
|
||||
@@ -1283,20 +1283,97 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
f'Successfully updated agent-server conversation {conversation_id} title to "{new_title}"'
|
||||
)
|
||||
|
||||
def _validate_repository_update(
|
||||
self,
|
||||
request: AppConversationUpdateRequest,
|
||||
existing_branch: str | None = None,
|
||||
) -> None:
|
||||
"""Validate repository-related fields in the update request.
|
||||
|
||||
Args:
|
||||
request: The update request containing fields to validate
|
||||
existing_branch: The conversation's current branch (if any)
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails
|
||||
"""
|
||||
# Check if repository is being set
|
||||
if 'selected_repository' in request.model_fields_set:
|
||||
repo = request.selected_repository
|
||||
if repo is not None:
|
||||
# Validate repository format (owner/repo)
|
||||
if '/' not in repo or repo.count('/') != 1:
|
||||
raise ValueError(
|
||||
f"Invalid repository format: '{repo}'. Expected 'owner/repo'."
|
||||
)
|
||||
|
||||
# Sanitize: check for dangerous characters
|
||||
if any(c in repo for c in [';', '&', '|', '$', '`', '\n', '\r']):
|
||||
raise ValueError(f"Invalid characters in repository name: '{repo}'")
|
||||
|
||||
# If setting a repository, branch should also be provided
|
||||
# (either in this request or already exists in conversation)
|
||||
if (
|
||||
'selected_branch' not in request.model_fields_set
|
||||
and existing_branch is None
|
||||
):
|
||||
_logger.warning(
|
||||
f'Repository {repo} set without branch in the same request '
|
||||
'and no existing branch in conversation'
|
||||
)
|
||||
else:
|
||||
# Repository is being removed (set to null)
|
||||
# Enforce consistency: branch and provider must also be cleared
|
||||
if 'selected_branch' in request.model_fields_set:
|
||||
if request.selected_branch is not None:
|
||||
raise ValueError(
|
||||
'When removing repository, branch must also be cleared'
|
||||
)
|
||||
if 'git_provider' in request.model_fields_set:
|
||||
if request.git_provider is not None:
|
||||
raise ValueError(
|
||||
'When removing repository, git_provider must also be cleared'
|
||||
)
|
||||
|
||||
# Validate branch if provided
|
||||
if 'selected_branch' in request.model_fields_set:
|
||||
branch = request.selected_branch
|
||||
if branch is not None:
|
||||
# Sanitize: check for dangerous characters
|
||||
if any(c in branch for c in [';', '&', '|', '$', '`', '\n', '\r', ' ']):
|
||||
raise ValueError(f"Invalid characters in branch name: '{branch}'")
|
||||
|
||||
async def update_app_conversation(
|
||||
self, conversation_id: UUID, request: AppConversationUpdateRequest
|
||||
) -> AppConversation | None:
|
||||
"""Update an app conversation and return it. Return None if the conversation
|
||||
did not exist.
|
||||
"""Update an app conversation and return it.
|
||||
|
||||
Return None if the conversation did not exist.
|
||||
|
||||
Only fields that are explicitly set in the request will be updated.
|
||||
This allows partial updates where only specific fields are modified.
|
||||
Fields can be set to None to clear them (e.g., removing a repository).
|
||||
|
||||
Raises:
|
||||
ValueError: If repository/branch validation fails
|
||||
"""
|
||||
info = await self.app_conversation_info_service.get_app_conversation_info(
|
||||
conversation_id
|
||||
)
|
||||
if info is None:
|
||||
return None
|
||||
for field_name in AppConversationUpdateRequest.model_fields:
|
||||
|
||||
# Validate repository-related fields before updating
|
||||
# Pass existing branch to avoid false warnings when only updating repository
|
||||
self._validate_repository_update(request, existing_branch=info.selected_branch)
|
||||
|
||||
# Only update fields that were explicitly provided in the request
|
||||
# This uses Pydantic's model_fields_set to detect which fields were set,
|
||||
# allowing us to distinguish between "not provided" and "explicitly set to None"
|
||||
for field_name in request.model_fields_set:
|
||||
value = getattr(request, field_name)
|
||||
setattr(info, field_name, value)
|
||||
|
||||
info = await self.app_conversation_info_service.save_app_conversation_info(info)
|
||||
conversations = await self._build_app_conversations([info])
|
||||
return conversations[0]
|
||||
|
||||
@@ -1680,8 +1680,8 @@ async def test_condenser_metrics_included(mock_agent_with_stats, test_event_stre
|
||||
assert last_action.llm_metrics is not None
|
||||
|
||||
# Verify that both agent and condenser metrics are included
|
||||
assert (
|
||||
last_action.llm_metrics.accumulated_cost == 0.08
|
||||
assert last_action.llm_metrics.accumulated_cost == pytest.approx(
|
||||
0.08
|
||||
) # 0.05 from agent + 0.03 from condenser
|
||||
|
||||
# The accumulated token usage should include both agent and condenser metrics
|
||||
|
||||
Reference in New Issue
Block a user