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:
MkDev11
2026-02-25 03:09:12 -08:00
committed by GitHub
parent dc039d81d6
commit 51b989b5f8
15 changed files with 1431 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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": "会話が見つかりません",

View File

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

View File

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

View File

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