From 840f7c95046a142012a46819ce6d460fff2789ee Mon Sep 17 00:00:00 2001 From: Caio Gallo Date: Tue, 24 Mar 2026 16:06:58 -0300 Subject: [PATCH 1/3] fix: render branch creation dialog in TaskInput screen --- .../task-detail/components/TaskInput.tsx | 66 ++++++++++++++++++- apps/code/src/shared/types/analytics.ts | 3 +- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 7cdc05fa2..412197e76 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -3,7 +3,14 @@ import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker"; import { useFolders } from "@features/folders/hooks/useFolders"; import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; +import { GitBranchDialog } from "@features/git-interaction/components/GitInteractionDialogs"; import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; +import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; +import { + sanitizeBranchName, + validateBranchName, +} from "@features/git-interaction/utils/branchNameValidation"; +import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; import { ModeIndicatorInput } from "@features/message-editor/components/ModeIndicatorInput"; import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; @@ -22,7 +29,7 @@ import { import { Flex } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc/client"; import { useNavigationStore } from "@stores/navigationStore"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { usePreviewSession } from "../hooks/usePreviewSession"; @@ -94,6 +101,51 @@ export function TaskInput() { const cloudBranches = cloudBranchData?.branches; const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; + const { + branchOpen, + branchName: newBranchName, + branchError, + actions: gitActions, + } = useGitInteractionStore(); + + const createBranchMutation = useMutation( + trpcReact.git.createBranch.mutationOptions({ + onSuccess: (_data, { branchName }) => { + if (selectedDirectory) invalidateGitBranchQueries(selectedDirectory); + setSelectedBranch(branchName); + gitActions.closeBranch(); + }, + onError: (error) => { + const message = + error instanceof Error ? error.message : "Failed to create branch."; + gitActions.setBranchError(message); + }, + }), + ); + + const handleNewBranchNameChange = useCallback( + (value: string) => { + const sanitized = sanitizeBranchName(value); + gitActions.setBranchName(sanitized); + gitActions.setBranchError(validateBranchName(sanitized)); + }, + [gitActions], + ); + + const handleCreateBranch = useCallback(() => { + const trimmed = newBranchName.trim(); + if (!trimmed || !selectedDirectory) return; + const validationError = validateBranchName(trimmed); + if (validationError) { + gitActions.setBranchError(validationError); + return; + } + createBranchMutation.mutate({ + directoryPath: selectedDirectory, + branchName: trimmed, + }); + }, [newBranchName, selectedDirectory, gitActions, createBranchMutation]); + // Preview session provides adapter-specific config options const { modeOption, @@ -398,6 +450,18 @@ export function TaskInput() { /> + + { + if (!open) gitActions.closeBranch(); + }} + branchName={newBranchName} + onBranchNameChange={handleNewBranchNameChange} + onConfirm={handleCreateBranch} + isSubmitting={createBranchMutation.isPending} + error={branchError} + /> ); } diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 526ae1dfe..1b26ebd60 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -13,7 +13,8 @@ type GitActionType = | "commit-push" | "create-pr" | "view-pr" - | "update-pr"; + | "update-pr" + | "branch-here"; export type FeedbackType = "good" | "bad" | "general"; type FileOpenSource = "sidebar" | "agent-suggestion" | "search" | "diff"; type FileChangeType = "added" | "modified" | "deleted"; From bb8727823d452e4078711b4e2f9b9c1cbca66c31 Mon Sep 17 00:00:00 2001 From: Caio Gallo Date: Tue, 24 Mar 2026 17:59:59 -0300 Subject: [PATCH 2/3] refactor(git): centralize branch creation logic across TaskInput and git interaction --- .../hooks/useGitInteraction.ts | 40 ++++---- .../git-interaction/utils/branchCreation.ts | 92 +++++++++++++++++++ .../task-detail/components/TaskInput.tsx | 61 ++++++------ 3 files changed, 135 insertions(+), 58 deletions(-) create mode 100644 apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts b/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts index 2a20c2d07..c0f093c85 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts +++ b/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts @@ -11,10 +11,9 @@ import type { GitMenuActionId, } from "@features/git-interaction/types"; import { - sanitizeBranchName, - validateBranchName, -} from "@features/git-interaction/utils/branchNameValidation"; -import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; + createBranch, + getBranchNameInputState, +} from "@features/git-interaction/utils/branchCreation"; import { updateGitCacheFromSnapshot } from "@features/git-interaction/utils/updateGitCache"; import { trpc, trpcClient } from "@renderer/trpc"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; @@ -480,30 +479,25 @@ export function useGitInteraction( const runBranch = async () => { if (!repoPath) return; - const branchName = store.branchName.trim(); - if (!branchName) { - modal.setBranchError("Branch name is required."); - return; - } - - const validationError = validateBranchName(branchName); - if (validationError) { - modal.setBranchError(validationError); - return; - } - modal.setIsSubmitting(true); modal.setBranchError(null); try { - await trpcClient.git.createBranch.mutate({ - directoryPath: repoPath, - branchName, + const result = await createBranch({ + repoPath, + rawBranchName: store.branchName, }); + if (!result.success) { + if (result.reason === "request") { + log.error("Failed to create branch", result.rawError ?? result.error); + trackGitAction(taskId, "branch-here", false); + } - trackGitAction(taskId, "branch-here", true); + modal.setBranchError(result.error); + return; + } - invalidateGitBranchQueries(repoPath); + trackGitAction(taskId, "branch-here", true); await queryClient.invalidateQueries(trpc.workspace.getAll.pathFilter()); modal.closeBranch(); @@ -547,9 +541,9 @@ export function useGitInteraction( setPrTitle: modal.setPrTitle, setPrBody: modal.setPrBody, setBranchName: (value: string) => { - const sanitized = sanitizeBranchName(value); + const { sanitized, error } = getBranchNameInputState(value); modal.setBranchName(sanitized); - modal.setBranchError(validateBranchName(sanitized)); + modal.setBranchError(error); }, runCommit, runPush, diff --git a/apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts b/apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts new file mode 100644 index 000000000..60b095509 --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts @@ -0,0 +1,92 @@ +import { + sanitizeBranchName, + validateBranchName, +} from "@features/git-interaction/utils/branchNameValidation"; +import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; +import { trpcClient } from "@renderer/trpc"; + +export interface BranchNameInputState { + sanitized: string; + error: string | null; +} + +export type CreateBranchResult = + | { + success: true; + branchName: string; + } + | { + success: false; + error: string; + reason: "missing-repo" | "validation" | "request"; + rawError?: unknown; + }; + +interface CreateBranchInput { + repoPath?: string; + rawBranchName: string; +} + +function getCreateBranchError(error: unknown): string { + return error instanceof Error ? error.message : "Failed to create branch."; +} + +export function getBranchNameInputState(value: string): BranchNameInputState { + const sanitized = sanitizeBranchName(value); + return { + sanitized, + error: validateBranchName(sanitized), + }; +} + +export async function createBranch({ + repoPath, + rawBranchName, +}: CreateBranchInput): Promise { + if (!repoPath) { + return { + success: false, + error: "Select a repository folder first.", + reason: "missing-repo", + }; + } + + const branchName = rawBranchName.trim(); + if (!branchName) { + return { + success: false, + error: "Branch name is required.", + reason: "validation", + }; + } + + const validationError = validateBranchName(branchName); + if (validationError) { + return { + success: false, + error: validationError, + reason: "validation", + }; + } + + try { + await trpcClient.git.createBranch.mutate({ + directoryPath: repoPath, + branchName, + }); + + invalidateGitBranchQueries(repoPath); + + return { + success: true, + branchName, + }; + } catch (error) { + return { + success: false, + error: getCreateBranchError(error), + reason: "request", + rawError: error, + }; + } +} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 412197e76..630bea5b1 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -7,10 +7,9 @@ import { GitBranchDialog } from "@features/git-interaction/components/GitInterac import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; import { - sanitizeBranchName, - validateBranchName, -} from "@features/git-interaction/utils/branchNameValidation"; -import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; + createBranch, + getBranchNameInputState, +} from "@features/git-interaction/utils/branchCreation"; import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; import { ModeIndicatorInput } from "@features/message-editor/components/ModeIndicatorInput"; import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; @@ -29,7 +28,7 @@ import { import { Flex } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc/client"; import { useNavigationStore } from "@stores/navigationStore"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { usePreviewSession } from "../hooks/usePreviewSession"; @@ -64,6 +63,7 @@ export function TaskInput() { const [editorIsEmpty, setEditorIsEmpty] = useState(true); const [isDraggingFile, setIsDraggingFile] = useState(false); + const [isCreatingBranch, setIsCreatingBranch] = useState(false); const [selectedBranch, setSelectedBranch] = useState(null); const [selectedEnvironment, setSelectedEnvironmentRaw] = useState< string | null @@ -108,43 +108,34 @@ export function TaskInput() { actions: gitActions, } = useGitInteractionStore(); - const createBranchMutation = useMutation( - trpcReact.git.createBranch.mutationOptions({ - onSuccess: (_data, { branchName }) => { - if (selectedDirectory) invalidateGitBranchQueries(selectedDirectory); - setSelectedBranch(branchName); - gitActions.closeBranch(); - }, - onError: (error) => { - const message = - error instanceof Error ? error.message : "Failed to create branch."; - gitActions.setBranchError(message); - }, - }), - ); - const handleNewBranchNameChange = useCallback( (value: string) => { - const sanitized = sanitizeBranchName(value); + const { sanitized, error } = getBranchNameInputState(value); gitActions.setBranchName(sanitized); - gitActions.setBranchError(validateBranchName(sanitized)); + gitActions.setBranchError(error); }, [gitActions], ); - const handleCreateBranch = useCallback(() => { - const trimmed = newBranchName.trim(); - if (!trimmed || !selectedDirectory) return; - const validationError = validateBranchName(trimmed); - if (validationError) { - gitActions.setBranchError(validationError); - return; + const handleCreateBranch = useCallback(async () => { + setIsCreatingBranch(true); + + try { + const result = await createBranch({ + repoPath: selectedDirectory || undefined, + rawBranchName: newBranchName, + }); + if (!result.success) { + gitActions.setBranchError(result.error); + return; + } + + setSelectedBranch(result.branchName); + gitActions.closeBranch(); + } finally { + setIsCreatingBranch(false); } - createBranchMutation.mutate({ - directoryPath: selectedDirectory, - branchName: trimmed, - }); - }, [newBranchName, selectedDirectory, gitActions, createBranchMutation]); + }, [selectedDirectory, newBranchName, gitActions]); // Preview session provides adapter-specific config options const { @@ -459,7 +450,7 @@ export function TaskInput() { branchName={newBranchName} onBranchNameChange={handleNewBranchNameChange} onConfirm={handleCreateBranch} - isSubmitting={createBranchMutation.isPending} + isSubmitting={isCreatingBranch} error={branchError} /> From 46f1840fe633c8492041af0e017f7ce37c067032 Mon Sep 17 00:00:00 2001 From: Caio Gallo Date: Wed, 25 Mar 2026 08:02:30 -0300 Subject: [PATCH 3/3] test(git): cover branch creation utility success and failure paths --- .../utils/branchCreation.test.ts | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts diff --git a/apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts b/apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts new file mode 100644 index 000000000..90ba900ef --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockCreateBranchMutate, mockInvalidateGitBranchQueries } = vi.hoisted( + () => ({ + mockCreateBranchMutate: vi.fn(), + mockInvalidateGitBranchQueries: vi.fn(), + }), +); + +vi.mock("@renderer/trpc", () => ({ + trpcClient: { + git: { + createBranch: { + mutate: mockCreateBranchMutate, + }, + }, + }, +})); + +vi.mock("@features/git-interaction/utils/gitCacheKeys", () => ({ + invalidateGitBranchQueries: mockInvalidateGitBranchQueries, +})); + +import { createBranch, getBranchNameInputState } from "./branchCreation"; + +describe("branchCreation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getBranchNameInputState", () => { + it("sanitizes spaces and returns no error for valid names", () => { + expect(getBranchNameInputState("feature my branch")).toEqual({ + sanitized: "feature-my-branch", + error: null, + }); + }); + + it("returns validation errors for invalid names", () => { + expect(getBranchNameInputState("feature..branch")).toEqual({ + sanitized: "feature..branch", + error: 'Branch name cannot contain "..".', + }); + }); + }); + + describe("createBranch", () => { + it("returns missing-repo error when repo path is not provided", async () => { + const result = await createBranch({ + repoPath: undefined, + rawBranchName: "feature/test", + }); + + expect(result).toEqual({ + success: false, + error: "Select a repository folder first.", + reason: "missing-repo", + }); + expect(mockCreateBranchMutate).not.toHaveBeenCalled(); + expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + }); + + it("returns validation error for empty branch name", async () => { + const result = await createBranch({ + repoPath: "/repo", + rawBranchName: " ", + }); + + expect(result).toEqual({ + success: false, + error: "Branch name is required.", + reason: "validation", + }); + expect(mockCreateBranchMutate).not.toHaveBeenCalled(); + expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + }); + + it("returns validation error for invalid branch names", async () => { + const result = await createBranch({ + repoPath: "/repo", + rawBranchName: "feature..branch", + }); + + expect(result).toEqual({ + success: false, + error: 'Branch name cannot contain "..".', + reason: "validation", + }); + expect(mockCreateBranchMutate).not.toHaveBeenCalled(); + expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + }); + + it("creates branch with trimmed name and invalidates branch queries", async () => { + mockCreateBranchMutate.mockResolvedValueOnce(undefined); + + const result = await createBranch({ + repoPath: "/repo", + rawBranchName: " feature/test ", + }); + + expect(mockCreateBranchMutate).toHaveBeenCalledWith({ + directoryPath: "/repo", + branchName: "feature/test", + }); + expect(mockInvalidateGitBranchQueries).toHaveBeenCalledWith("/repo"); + expect(result).toEqual({ + success: true, + branchName: "feature/test", + }); + }); + + it("returns request error with message when mutate throws Error", async () => { + const error = new Error("boom"); + mockCreateBranchMutate.mockRejectedValueOnce(error); + + const result = await createBranch({ + repoPath: "/repo", + rawBranchName: "feature/test", + }); + + expect(result).toEqual({ + success: false, + error: "boom", + reason: "request", + rawError: error, + }); + expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + }); + + it("returns fallback error when mutate throws non-Error value", async () => { + mockCreateBranchMutate.mockRejectedValueOnce("oops"); + + const result = await createBranch({ + repoPath: "/repo", + rawBranchName: "feature/test", + }); + + expect(result).toEqual({ + success: false, + error: "Failed to create branch.", + reason: "request", + rawError: "oops", + }); + expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + }); + }); +});