diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.test.tsx b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.test.tsx
new file mode 100644
index 000000000..b76f10229
--- /dev/null
+++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.test.tsx
@@ -0,0 +1,153 @@
+import { Theme } from "@radix-ui/themes";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@features/git-interaction/state/gitInteractionStore", () => ({
+ useGitInteractionStore: () => ({ actions: { openBranch: vi.fn() } }),
+}));
+
+vi.mock("@features/git-interaction/utils/getSuggestedBranchName", () => ({
+ getSuggestedBranchName: vi.fn(() => null),
+}));
+
+vi.mock("@features/git-interaction/utils/gitCacheKeys", () => ({
+ invalidateGitBranchQueries: vi.fn(),
+}));
+
+vi.mock("@renderer/trpc", () => ({
+ useTRPC: () => ({
+ git: {
+ getAllBranches: { queryOptions: () => ({ queryKey: ["mock"] }) },
+ checkoutBranch: { mutationOptions: () => ({}) },
+ },
+ }),
+}));
+
+vi.mock("@renderer/utils/toast", () => ({
+ toast: { error: vi.fn() },
+}));
+
+const mutateMock = vi.fn();
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ data: [], isLoading: false }),
+ useMutation: () => ({ mutate: mutateMock }),
+}));
+
+import { BranchSelector } from "./BranchSelector";
+
+function renderInTheme(children: React.ReactElement) {
+ return render({children});
+}
+
+describe("BranchSelector cloud mode", () => {
+ it("keeps the trigger enabled while the initial cloud load is in flight", () => {
+ renderInTheme(
+ ,
+ );
+
+ expect(screen.getByRole("combobox", { name: "Branch" })).toBeEnabled();
+ });
+
+ it("surfaces the 'Use input as branch name' action when the typed value is new", async () => {
+ const user = userEvent.setup();
+ renderInTheme(
+ ,
+ );
+
+ await user.click(screen.getByRole("combobox", { name: "Branch" }));
+
+ expect(
+ await screen.findByText('Use "brand-new-branch" as branch name'),
+ ).toBeInTheDocument();
+ });
+
+ it("hides the typed-name action when the input exactly matches an existing branch", async () => {
+ const user = userEvent.setup();
+ renderInTheme(
+ ,
+ );
+
+ await user.click(screen.getByRole("combobox", { name: "Branch" }));
+
+ expect(
+ screen.queryByText(/Use "main" as branch name/),
+ ).not.toBeInTheDocument();
+ });
+
+ it("commits the typed value via onBranchSelect when the sentinel action is selected", async () => {
+ const user = userEvent.setup();
+ const onBranchSelect = vi.fn();
+ renderInTheme(
+ ,
+ );
+
+ await user.click(screen.getByRole("combobox", { name: "Branch" }));
+ await user.click(
+ await screen.findByText('Use "brand-new-branch" as branch name'),
+ );
+
+ expect(onBranchSelect).toHaveBeenCalledWith("brand-new-branch");
+ });
+
+ it("invokes onCloudBranchCommit when the typed value is committed (so the parent can reset the search)", async () => {
+ const user = userEvent.setup();
+ const onCloudBranchCommit = vi.fn();
+ renderInTheme(
+ ,
+ );
+
+ await user.click(screen.getByRole("combobox", { name: "Branch" }));
+ await user.click(
+ await screen.findByText('Use "brand-new-branch" as branch name'),
+ );
+
+ expect(onCloudBranchCommit).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx
index fb4c1e16b..0f00fce18 100644
--- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx
+++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx
@@ -36,6 +36,13 @@ const COMBOBOX_LIMIT = 50;
// plain button the combobox's roving focus skips over.
const CREATE_BRANCH_ACTION = "__create_branch__";
+// Sentinel for the "Use '' as branch name" action in cloud mode.
+// Positioned first when the list is empty so it's auto-highlighted while the
+// (slow) remote search is still running; pushed into the footer once branches
+// have loaded so auto-highlight lands on a real branch (typed names that match
+// a server-returned prefix would otherwise be shadowed by the literal input).
+const USE_INPUT_BRANCH_ACTION = "__use_input_branch__";
+
function LoadingRow({ label }: { label: string }) {
return (
@@ -136,8 +143,6 @@ export function BranchSelector({
const branches = isCloudMode ? (cloudBranches ?? []) : localBranches;
const effectiveLoading = loading || (isCloudMode && cloudBranchesLoading);
- const cloudStillLoading =
- isCloudMode && cloudBranchesLoading && branches.length === 0 && !open;
const branchListLoading = isCloudMode
? !!cloudBranchesLoading
: localBranchesLoading;
@@ -164,6 +169,40 @@ export function BranchSelector({
}),
);
+ // In local mode, surface in-progress git operations (rebase/merge/etc.) so the
+ // user understands why there's no current branch and why we won't let them
+ // checkout a different one — checkout would fail with a hard-to-read git error.
+ const localBusy = !isSelectionOnly && busyState?.busy === true;
+ const busyOperationLabel =
+ localBusy && busyState?.busy
+ ? BUSY_OPERATION_LABEL[busyState.operation]
+ : null;
+
+ const displayText = effectiveLoading
+ ? "Loading..."
+ : busyOperationLabel && !displayedBranch
+ ? busyOperationLabel
+ : (displayedBranch ?? "No branch");
+
+ const showSpinner =
+ effectiveLoading || (isCloudMode && open && cloudBranchesFetchingMore);
+
+ const isDisabled = !!(disabled || !repoPath || localBusy);
+ const disabledReason =
+ localBusy && busyOperationLabel
+ ? `${busyOperationLabel} in progress — finish or abort it to switch branches.`
+ : null;
+ const inputValue = isCloudMode ? (cloudSearchQuery ?? "") : searchQuery;
+ const trimmedInputValue = inputValue.trim();
+ const canUseInputBranch =
+ !isDisabled &&
+ trimmedInputValue.length > 0 &&
+ trimmedInputValue !== displayedBranch;
+ const showUseInputBranchAction =
+ isCloudMode &&
+ canUseInputBranch &&
+ !branches.some((branch) => branch === trimmedInputValue);
+
const handleBranchChange = (value: string | null) => {
if (!value) return;
if (value === CREATE_BRANCH_ACTION) {
@@ -175,12 +214,15 @@ export function BranchSelector({
);
return;
}
+ const branchName =
+ value === USE_INPUT_BRANCH_ACTION ? trimmedInputValue : value;
+ if (!branchName) return;
if (isSelectionOnly) {
- onBranchSelect?.(value);
- } else if (value !== currentBranch) {
+ onBranchSelect?.(branchName);
+ } else if (branchName !== currentBranch) {
checkoutMutation.mutate({
directoryPath: repoPath as string,
- branchName: value,
+ branchName,
});
}
if (isCloudMode) {
@@ -198,49 +240,28 @@ export function BranchSelector({
}
};
- // In local mode, surface in-progress git operations (rebase/merge/etc.) so the
- // user understands why there's no current branch and why we won't let them
- // checkout a different one — checkout would fail with a hard-to-read git error.
- const localBusy = !isSelectionOnly && busyState?.busy === true;
- const busyOperationLabel =
- localBusy && busyState?.busy
- ? BUSY_OPERATION_LABEL[busyState.operation]
- : null;
-
- const displayText = effectiveLoading
- ? "Loading..."
- : busyOperationLabel && !displayedBranch
- ? busyOperationLabel
- : (displayedBranch ?? "No branch");
-
- const showSpinner =
- effectiveLoading || (isCloudMode && open && cloudBranchesFetchingMore);
-
- const isDisabled = !!(
- disabled ||
- !repoPath ||
- cloudStillLoading ||
- localBusy
- );
- const disabledReason =
- localBusy && busyOperationLabel
- ? `${busyOperationLabel} in progress — finish or abort it to switch branches.`
- : null;
- const inputValue = isCloudMode ? (cloudSearchQuery ?? "") : searchQuery;
- const trimmedInputValue = inputValue.trim();
- const canUseInputBranch =
- !isDisabled &&
- trimmedInputValue.length > 0 &&
- trimmedInputValue !== displayedBranch;
-
const handleUseInputBranch = () => {
if (!canUseInputBranch) return;
handleBranchChange(trimmedInputValue);
};
+ const useInputBranchPosition: "leading" | "trailing" | null =
+ showUseInputBranchAction
+ ? branches.length === 0
+ ? "leading"
+ : "trailing"
+ : null;
+ const comboboxItems = isCloudMode
+ ? useInputBranchPosition === "leading"
+ ? [USE_INPUT_BRANCH_ACTION, ...branches]
+ : useInputBranchPosition === "trailing"
+ ? [...branches, USE_INPUT_BRANCH_ACTION]
+ : branches
+ : [...branches, CREATE_BRANCH_ACTION];
+
return (
- {(item: string) =>
- item === CREATE_BRANCH_ACTION ? (
-
+ {(item: string) => {
+ if (item === CREATE_BRANCH_ACTION) {
+ return (
+
+
+
+ Create new branch
+
+
+ );
+ }
+ if (item === USE_INPUT_BRANCH_ACTION) {
+ const useInputItem = (
- Create new branch
+ Use "{trimmedInputValue}" as branch name
-
- ) : (
+ );
+ if (useInputBranchPosition === "trailing") {
+ return (
+
+ {useInputItem}
+
+ );
+ }
+ return useInputItem;
+ }
+ return (
{item}
- )
- }
+ );
+ }}
{isCloudMode && cloudBranchesHasMore ? (
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 eb00b588c..b6e3fb96c 100644
--- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx
+++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx
@@ -728,6 +728,7 @@ export function TaskInput({
cloudSearchQuery={cloudBranchSearchQuery}
onCloudPickerClose={handleCloudBranchPickerClose}
onCloudSearchChange={handleCloudBranchSearchChange}
+ onCloudBranchCommit={handleCloudBranchPickerClose}
onCloudLoadMore={handleLoadMoreCloudBranches}
onRefresh={
workspaceMode === "cloud"
diff --git a/apps/code/src/renderer/hooks/useIntegrations.ts b/apps/code/src/renderer/hooks/useIntegrations.ts
index 4c07c38b4..c8761ac3c 100644
--- a/apps/code/src/renderer/hooks/useIntegrations.ts
+++ b/apps/code/src/renderer/hooks/useIntegrations.ts
@@ -5,6 +5,7 @@ import {
useIntegrationSelectors,
useIntegrationStore,
} from "@features/integrations/stores/integrationStore";
+import { useDebounce } from "@hooks/useDebounce";
import type { UserGitHubIntegration } from "@renderer/api/posthogClient";
import { useQueries, useQueryClient } from "@tanstack/react-query";
import {
@@ -17,6 +18,12 @@ import {
import { useAuthenticatedInfiniteQuery } from "./useAuthenticatedInfiniteQuery";
import { useAuthenticatedQuery } from "./useAuthenticatedQuery";
+// Branch search hits a slow remote endpoint (GitHub via PostHog proxy). Debounce
+// keystrokes so we fire at most one request per typing burst. Empty searches
+// skip the debounce so closing the picker (which resets search to "") clears
+// stale results immediately.
+const BRANCH_SEARCH_DEBOUNCE_MS = 300;
+
const integrationKeys = {
all: ["integrations"] as const,
list: () => [...integrationKeys.all, "list"] as const,
@@ -366,11 +373,15 @@ export function useGithubBranches(
search?: string,
enabled: boolean = true,
) {
- const deferredSearch = useDeferredValue(search?.trim() ?? "");
+ const trimmedSearch = search?.trim() ?? "";
+ const debouncedSearch = useDebounce(
+ trimmedSearch,
+ trimmedSearch ? BRANCH_SEARCH_DEBOUNCE_MS : 0,
+ );
const queryEnabled = enabled && !!integrationId && !!repo;
const query = useAuthenticatedInfiniteQuery(
- integrationKeys.branches(integrationId, repo, deferredSearch),
+ integrationKeys.branches(integrationId, repo, debouncedSearch),
async (client, offset) => {
if (!integrationId || !repo) {
return { branches: [], defaultBranch: null, hasMore: false };
@@ -382,7 +393,7 @@ export function useGithubBranches(
repo,
offset,
pageSize,
- deferredSearch,
+ debouncedSearch,
);
},
{
@@ -435,11 +446,15 @@ export function useUserGithubBranches(
search?: string,
enabled: boolean = true,
) {
- const deferredSearch = useDeferredValue(search?.trim() ?? "");
+ const trimmedSearch = search?.trim() ?? "";
+ const debouncedSearch = useDebounce(
+ trimmedSearch,
+ trimmedSearch ? BRANCH_SEARCH_DEBOUNCE_MS : 0,
+ );
const queryEnabled = enabled && !!installationId && !!repo;
const query = useAuthenticatedInfiniteQuery(
- userGithubIntegrationKeys.branches(installationId, repo, deferredSearch),
+ userGithubIntegrationKeys.branches(installationId, repo, debouncedSearch),
async (client, offset) => {
if (!installationId || !repo) {
return { branches: [], defaultBranch: null, hasMore: false };
@@ -451,7 +466,7 @@ export function useUserGithubBranches(
repo,
offset,
pageSize,
- deferredSearch,
+ debouncedSearch,
);
},
{