authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-24 21:58:35+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 11:58:35-07:00
log84c436bcf714076572804d005eb7123ca1588188
tree35a96158ce408dfc60529a30189f5e325b6aee67
parentffb5fcce7336f9b3d5bde3a5f496a816907663af
signature Signed by PGP key B5690EEEBB952194

fix(mobile): branch search finds remote and space-typed branches (#13454)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: GPT-6 Sol <noreply@openai.com>

5 files changed, 63 insertions(+), 26 deletions(-)

apps/mobile/src/features/threads/new-task-context-presentation.test.ts+24
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 filterNewTaskBranches,
4 resolveNewTaskBranchWorktreePath,5 resolveNewTaskBranchWorktreePath,
5 resolveNewTaskBranchLabel,6 resolveNewTaskBranchLabel,
6 resolveNewTaskLocalWorkspaceSelection,7 resolveNewTaskLocalWorkspaceSelection,
...@@ -126,3 +127,26 @@ describe("resolveNewTaskBranchLabel", () => {...@@ -126,3 +127,26 @@ describe("resolveNewTaskBranchLabel", () => {
126 ).toBe("Choose branch");127 ).toBe("Choose branch");
127 });128 });
128});129});
130
131describe("filterNewTaskBranches", () => {
132 const branches = [
133 { name: "main", isRemote: false },
134 { name: "Feature/Login-Page", isRemote: false },
135 { name: "origin/fix/remote-only", isRemote: true },
136 ];
137 const search = (query: string) =>
138 filterNewTaskBranches(branches, query).map((branch) => branch.name);
139
140 it("ignores case in both the query and the branch name", () => {
141 expect(search("feature/login")).toEqual(["Feature/Login-Page"]);
142 expect(search("MAIN")).toEqual(["main"]);
143 });
144
145 it("keeps remote-only branches searchable", () => {
146 expect(search("remote-only")).toEqual(["origin/fix/remote-only"]);
147 });
148
149 it("matches a typed space against the dash a branch name uses", () => {
150 expect(search(" login page ")).toEqual(["Feature/Login-Page"]);
151 });
152});
apps/mobile/src/features/threads/new-task-context-presentation.ts+12
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { sanitizeNewRefName } from "@t3tools/shared/git";
2
1type WorkspaceMode = "local" | "worktree";3type WorkspaceMode = "local" | "worktree";
24
3export function resolveNewTaskWorkspaceLabel(input: {5export function resolveNewTaskWorkspaceLabel(input: {
...@@ -81,3 +83,13 @@ export function shouldCheckoutNewTaskBranch(input: {...@@ -81,3 +83,13 @@ export function shouldCheckoutNewTaskBranch(input: {
81}): boolean {83}): boolean {
82 return input.workspaceMode === "local" && !input.branchIsCurrent && !input.branchWorktreePath;84 return input.workspaceMode === "local" && !input.branchIsCurrent && !input.branchWorktreePath;
83}85}
86
87export function filterNewTaskBranches<T extends { readonly name: string }>(
88 branches: ReadonlyArray<T>,
89 rawQuery: string,
90): ReadonlyArray<T> {
91 const query = sanitizeNewRefName(rawQuery).toLowerCase();
92 return query.length === 0
93 ? branches
94 : branches.filter((branch) => branch.name.toLowerCase().includes(query));
95}
apps/mobile/src/features/threads/new-task-flow-provider.tsx+12-13
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 T3_PROJECT_FILE_NAME,18 T3_PROJECT_FILE_NAME,
19 ThreadId,19 ThreadId,
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { sanitizeNewRefName } from "@t3tools/shared/git";
21import { resolveProjectSettings } from "@t3tools/shared/projectSettings";22import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
22import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";23import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
23import * as Arr from "effect/Array";24import * as Arr from "effect/Array";
...@@ -92,6 +93,7 @@ import {...@@ -92,6 +93,7 @@ import {
92} from "../../state/legacy-plan-mode";93} from "../../state/legacy-plan-mode";
93import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";94import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";
94import {95import {
96 filterNewTaskBranches,
95 resolveNewTaskBranchWorktreePath,97 resolveNewTaskBranchWorktreePath,
96 resolveNewTaskLocalWorkspaceSelection,98 resolveNewTaskLocalWorkspaceSelection,
97} from "./new-task-context-presentation";99} from "./new-task-context-presentation";
...@@ -129,6 +131,9 @@ export function branchBadgeLabel(input: {...@@ -129,6 +131,9 @@ export function branchBadgeLabel(input: {
129 if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) {131 if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) {
130 return "worktree";132 return "worktree";
131 }133 }
134 if (input.branch.isRemote) {
135 return "remote";
136 }
132 if (input.branch.isDefault) {137 if (input.branch.isDefault) {
133 return "default";138 return "default";
134 }139 }
...@@ -626,7 +631,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -626,7 +631,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
626 }631 }
627 replaceComposerDraftAttachments(selectedProjectDraftKey, []);632 replaceComposerDraftAttachments(selectedProjectDraftKey, []);
628 }, [selectedProjectDraftKey]);633 }, [selectedProjectDraftKey]);
629 const debouncedBranchQuery = useDebouncedValue(branchQuery, BRANCH_SEARCH_DEBOUNCE_MS);634 const branchSearchQuery = sanitizeNewRefName(branchQuery);
635 const debouncedBranchQuery = useDebouncedValue(branchSearchQuery, BRANCH_SEARCH_DEBOUNCE_MS);
630 const branchTarget = useMemo(636 const branchTarget = useMemo(
631 () => ({637 () => ({
632 environmentId: selectedProject?.environmentId ?? null,638 environmentId: selectedProject?.environmentId ?? null,
...@@ -637,7 +643,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -637,7 +643,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
637 [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot],643 [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot],
638 );644 );
639 const branchState = usePaginatedBranches(branchTarget);645 const branchState = usePaginatedBranches(branchTarget);
640 const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim();646 const branchSearchIsDebouncing = branchSearchQuery !== debouncedBranchQuery;
641 const branchesLoading =647 const branchesLoading =
642 branchSearchIsDebouncing || (branchState.isPending && branchState.data === null);648 branchSearchIsDebouncing || (branchState.isPending && branchState.data === null);
643 const branchesFetchingNextPage = branchState.isFetchingNextPage;649 const branchesFetchingNextPage = branchState.isFetchingNextPage;
...@@ -669,17 +675,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -669,17 +675,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
669 );675 );
670 const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null;676 const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null;
671677
672 const filteredBranches = useMemo(() => {678 const filteredBranches = useMemo(
673 const query = branchQuery.trim().toLowerCase();679 () => filterNewTaskBranches(allBranchRefs, branchQuery),
674 if (query.length === 0) {680 [allBranchRefs, branchQuery],
675 return availableBranches;681 );
676 }
677
678 return pipe(
679 availableBranches,
680 Arr.filter((branch) => branch.name.toLowerCase().includes(query)),
681 );
682 }, [availableBranches, branchQuery]);
683682
684 // The composer's draft follows the project it will be sent to: switching683 // The composer's draft follows the project it will be sent to: switching
685 // mid-compose keeps the same draft and moves it, so typed text follows the684 // mid-compose keeps the same draft and moves it, so typed text follows the
apps/web/src/components/BranchToolbar.logic.ts+2-13
...@@ -6,10 +6,12 @@ import type {...@@ -6,10 +6,12 @@ import type {
6 WorktreeSubmodules,6 WorktreeSubmodules,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import { sanitizeNewRefName } from "@t3tools/shared/git";
9import { toSortableTimestamp } from "../lib/threadSort";10import { toSortableTimestamp } from "../lib/threadSort";
10export {11export {
11 dedupeRemoteBranchesWithLocalMatches,12 dedupeRemoteBranchesWithLocalMatches,
12 deriveLocalBranchNameFromRemoteRef,13 deriveLocalBranchNameFromRemoteRef,
14 sanitizeNewRefName,
13} from "@t3tools/shared/git";15} from "@t3tools/shared/git";
1416
15export interface EnvironmentOption {17export interface EnvironmentOption {
...@@ -275,19 +277,6 @@ export function resolveBranchSelectionTarget(input: {...@@ -275,19 +277,6 @@ export function resolveBranchSelectionTarget(input: {
275 };277 };
276}278}
277279
278// Git rejects ASCII space and the ASCII control characters (tab, newline and
279// friends) in ref names, so the picker's "Create new ref" entry can only fail
280// for a typed name like "new branch". Replacing runs of those with a dash makes
281// the name usable without reimplementing check-ref-format: names invalid for
282// other reasons still surface the git error. Only the whitespace git actually
283// rejects is replaced — git accepts U+00A0 and friends, and rewriting those
284// would silently create a ref the user never asked for. Case and existing
285// dashes are left alone, since ref names are case sensitive and consecutive
286// dashes are valid.
287export function sanitizeNewRefName(rawName: string): string {
288 return rawName.trim().replace(/[ \t\n\r\f\v]+/g, "-");
289}
290
291export function shouldIncludeBranchPickerItem(input: {280export function shouldIncludeBranchPickerItem(input: {
292 itemValue: string;281 itemValue: string;
293 normalizedQuery: string;282 normalizedQuery: string;
packages/shared/src/git.ts+13
...@@ -265,6 +265,19 @@ function deriveLocalBranchNameCandidatesFromRemoteRef(...@@ -265,6 +265,19 @@ function deriveLocalBranchNameCandidatesFromRemoteRef(
265 return [...candidates];265 return [...candidates];
266}266}
267267
268// Git rejects ASCII space and the ASCII control characters (tab, newline and
269// friends) in ref names, so the picker's "Create new ref" entry can only fail
270// for a typed name like "new branch". Replacing runs of those with a dash makes
271// the name usable without reimplementing check-ref-format: names invalid for
272// other reasons still surface the git error. Only the whitespace git actually
273// rejects is replaced — git accepts U+00A0 and friends, and rewriting those
274// would silently create a ref the user never asked for. Case and existing
275// dashes are left alone, since ref names are case sensitive and consecutive
276// dashes are valid.
277export function sanitizeNewRefName(rawName: string): string {
278 return rawName.trim().replace(/[ \t\n\r\f\v]+/g, "-");
279}
280
268/**281/**
269 * Hide `origin/*` remote refs when a matching local refName already exists.282 * Hide `origin/*` remote refs when a matching local refName already exists.
270 */283 */