diff --git a/frontend/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx b/frontend/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx new file mode 100644 index 0000000000..01890d1b8b --- /dev/null +++ b/frontend/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx @@ -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 }) => ( + {gitProvider} + ), +})); + +// Mock GitExternalLinkIcon +vi.mock( + "#/components/features/chat/git-external-link-icon", + () => ({ + GitExternalLinkIcon: () => ( + external + ), + }), +); + +// Mock RepoForkedIcon +vi.mock("#/icons/repo-forked.svg?react", () => ({ + default: () => forked, +})); + +// Mock constructRepositoryUrl +vi.mock("#/utils/utils", async (importOriginal) => { + const actual = await importOriginal(); + 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( + , + ); + + 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( + , + ); + + expect(screen.getByTestId("git-provider-icon")).toBeInTheDocument(); + expect(screen.getByTestId("git-external-link-icon")).toBeInTheDocument(); + }); + + it("should not show repo forked icon", () => { + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.getByTestId("repo-forked-icon")).toBeInTheDocument(); + expect( + screen.queryByTestId("git-provider-icon"), + ).not.toBeInTheDocument(); + }); + + it("should not show external link icon", () => { + render( + , + ); + + 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( + , + ); + + await user.click(screen.getByRole("button")); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("should be disabled when disabled prop is true", () => { + render( + , + ); + + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + expect(button).toHaveClass("cursor-not-allowed"); + }); + + it("should be clickable when disabled prop is false", () => { + render( + , + ); + + 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( + , + ); + + await user.click(screen.getByRole("button")); + expect(handleClick).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/__tests__/components/features/chat/git-control-bar.test.tsx b/frontend/__tests__/components/features/chat/git-control-bar.test.tsx new file mode 100644 index 0000000000..659b9e7b94 --- /dev/null +++ b/frontend/__tests__/components/features/chat/git-control-bar.test.tsx @@ -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"); + }); +}); diff --git a/frontend/__tests__/components/features/chat/open-repository-modal.test.tsx b/frontend/__tests__/components/features/chat/open-repository-modal.test.tsx new file mode 100644 index 0000000000..7b6ef43752 --- /dev/null +++ b/frontend/__tests__/components/features/chat/open-repository-modal.test.tsx @@ -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; + }) => ( +
+ {providers.map((p: string) => ( + + ))} +
+ ), + }), +); + +// 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; + }) => ( + + ), + }), +); + +// Mock GitBranchDropdown +vi.mock( + "#/components/features/home/git-branch-dropdown/git-branch-dropdown", + () => ({ + GitBranchDropdown: ({ + onBranchSelect, + disabled, + }: { + onBranchSelect: (branch: { name: string } | null) => void; + disabled: boolean; + }) => ( + + ), + }), +); + +// Mock RepoForkedIcon +vi.mock("#/icons/repo-forked.svg?react", () => ({ + default: () =>
, +})); + +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( + , + ); + + expect( + screen.queryByText("CONVERSATION$OPEN_REPOSITORY"), + ).not.toBeInTheDocument(); + }); + + it("should render modal with title and description when open", () => { + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + expect( + screen.queryByTestId("git-provider-dropdown"), + ).not.toBeInTheDocument(); + }); + + it("should show provider dropdown when multiple providers exist", () => { + mockProviders.current = ["github", "gitlab"]; + + render( + , + ); + + 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( + , + ); + + // 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(); + }); + }); +}); diff --git a/frontend/__tests__/hooks/mutation/use-update-conversation-repository.test.tsx b/frontend/__tests__/hooks/mutation/use-update-conversation-repository.test.tsx new file mode 100644 index 0000000000..a635fca403 --- /dev/null +++ b/frontend/__tests__/hooks/mutation/use-update-conversation-repository.test.tsx @@ -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 }) => ( + {children} + ); +}; + +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); + }); + }); +}); diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index f6aaf91219..56942b54c4 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -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 { + const payload: Record = {}; + + 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( + `/api/v1/app-conversations/${conversationId}`, + payload, + ); + return data; + } + /** * Read a file from a specific conversation's sandbox workspace * @param conversationId The conversation ID diff --git a/frontend/src/components/features/chat/git-control-bar-repo-button.tsx b/frontend/src/components/features/chat/git-control-bar-repo-button.tsx index fee430dc5c..bd6159c11b 100644 --- a/frontend/src/components/features/chat/git-control-bar-repo-button.tsx +++ b/frontend/src/components/features/chat/git-control-bar-repo-button.tsx @@ -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 ( - -
- {hasRepository ? ( + if (hasRepository) { + return ( + +
- ) : ( - - )} +
+
+ {buttonText} +
+ +
+ ); + } + + return ( + ); } diff --git a/frontend/src/components/features/chat/git-control-bar.tsx b/frontend/src/components/features/chat/git-control-bar.tsx index 551d1e79c7..51d3b9fd06 100644 --- a/frontend/src/components/features/chat/git-control-bar.tsx +++ b/frontend/src/components/features/chat/git-control-bar.tsx @@ -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 (
- - - + setIsOpenRepoModalOpen(true)} + disabled={!isConversationReady} + /> ) : null}
+ + setIsOpenRepoModalOpen(false)} + onLaunch={handleLaunchRepository} + defaultProvider={gitProvider} + />
); } diff --git a/frontend/src/components/features/chat/open-repository-modal.tsx b/frontend/src/components/features/chat/open-repository-modal.tsx new file mode 100644 index 0000000000..e2806811b7 --- /dev/null +++ b/frontend/src/components/features/chat/open-repository-modal.tsx @@ -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( + null, + ); + const [selectedRepository, setSelectedRepository] = + useState(null); + const [selectedBranch, setSelectedBranch] = useState(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 ( + + +
+
+ + +
+ +
+ + {t(I18nKey.CONVERSATION$SELECT_OR_INSERT_LINK)} + + {providers.length > 1 && ( + + )} +
+
+ +
+ + + +
+ +
event.stopPropagation()} + > + + {t(I18nKey.BUTTON$LAUNCH)} + + + {t(I18nKey.BUTTON$CANCEL)} + +
+
+
+ ); +} diff --git a/frontend/src/hooks/mutation/use-update-conversation-repository.ts b/frontend/src/hooks/mutation/use-update-conversation-repository.ts new file mode 100644 index 0000000000..47bbed1515 --- /dev/null +++ b/frontend/src/hooks/mutation/use-update-conversation-repository.ts @@ -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"], + }); + }, + }); +}; diff --git a/frontend/src/hooks/use-send-message.ts b/frontend/src/hooks/use-send-message.ts index c6655b8230..4da5eafc2e 100644 --- a/frontend/src/hooks/use-send-message.ts +++ b/frontend/src/hooks/use-send-message.ts @@ -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) => { @@ -64,7 +71,7 @@ export function useSendMessage() { v0Send(event); } }, - [isV1Conversation, v1Context, v0Send], + [isV1Conversation, v1Context, v0Send, conversationId], ); return { send }; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 71f4c963b4..dd02d391d0 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 8db2e15e4f..a39cc33dcc 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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": "会話が見つかりません", diff --git a/openhands/app_server/app_conversation/app_conversation_models.py b/openhands/app_server/app_conversation/app_conversation_models.py index 83c3842c5d..a30b40e56c 100644 --- a/openhands/app_server/app_conversation/app_conversation_models.py +++ b/openhands/app_server/app_conversation/app_conversation_models.py @@ -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): diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 885c8dfcbf..b7edac10d0 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -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] diff --git a/tests/unit/controller/test_agent_controller.py b/tests/unit/controller/test_agent_controller.py index 469a940677..0294375f55 100644 --- a/tests/unit/controller/test_agent_controller.py +++ b/tests/unit/controller/test_agent_controller.py @@ -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