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 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import {
4 filterNewTaskBranches,
45 resolveNewTaskBranchWorktreePath,
56 resolveNewTaskBranchLabel,
67 resolveNewTaskLocalWorkspaceSelection,
......@@ -126,3 +127,26 @@ describe("resolveNewTaskBranchLabel", () => {
126127 ).toBe("Choose branch");
127128 });
128129});
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 @@
1import { sanitizeNewRefName } from "@t3tools/shared/git";
2
13type WorkspaceMode = "local" | "worktree";
24
35export function resolveNewTaskWorkspaceLabel(input: {
......@@ -81,3 +83,13 @@ export function shouldCheckoutNewTaskBranch(input: {
8183}): boolean {
8284 return input.workspaceMode === "local" && !input.branchIsCurrent && !input.branchWorktreePath;
8385}
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 {
1818 T3_PROJECT_FILE_NAME,
1919 ThreadId,
2020} from "@t3tools/contracts";
21import { sanitizeNewRefName } from "@t3tools/shared/git";
2122import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
2223import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
2324import * as Arr from "effect/Array";
......@@ -92,6 +93,7 @@ import {
9293} from "../../state/legacy-plan-mode";
9394import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";
9495import {
96 filterNewTaskBranches,
9597 resolveNewTaskBranchWorktreePath,
9698 resolveNewTaskLocalWorkspaceSelection,
9799} from "./new-task-context-presentation";
......@@ -129,6 +131,9 @@ export function branchBadgeLabel(input: {
129131 if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) {
130132 return "worktree";
131133 }
134 if (input.branch.isRemote) {
135 return "remote";
136 }
132137 if (input.branch.isDefault) {
133138 return "default";
134139 }
......@@ -626,7 +631,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
626631 }
627632 replaceComposerDraftAttachments(selectedProjectDraftKey, []);
628633 }, [selectedProjectDraftKey]);
629 const debouncedBranchQuery = useDebouncedValue(branchQuery, BRANCH_SEARCH_DEBOUNCE_MS);
634 const branchSearchQuery = sanitizeNewRefName(branchQuery);
635 const debouncedBranchQuery = useDebouncedValue(branchSearchQuery, BRANCH_SEARCH_DEBOUNCE_MS);
630636 const branchTarget = useMemo(
631637 () => ({
632638 environmentId: selectedProject?.environmentId ?? null,
......@@ -637,7 +643,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
637643 [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot],
638644 );
639645 const branchState = usePaginatedBranches(branchTarget);
640 const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim();
646 const branchSearchIsDebouncing = branchSearchQuery !== debouncedBranchQuery;
641647 const branchesLoading =
642648 branchSearchIsDebouncing || (branchState.isPending && branchState.data === null);
643649 const branchesFetchingNextPage = branchState.isFetchingNextPage;
......@@ -669,17 +675,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
669675 );
670676 const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null;
671677
672 const filteredBranches = useMemo(() => {
673 const query = branchQuery.trim().toLowerCase();
674 if (query.length === 0) {
675 return availableBranches;
676 }
677
678 return pipe(
679 availableBranches,
680 Arr.filter((branch) => branch.name.toLowerCase().includes(query)),
681 );
682 }, [availableBranches, branchQuery]);
678 const filteredBranches = useMemo(
679 () => filterNewTaskBranches(allBranchRefs, branchQuery),
680 [allBranchRefs, branchQuery],
681 );
683682
684683 // The composer's draft follows the project it will be sent to: switching
685684 // 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 {
66 WorktreeSubmodules,
77} from "@t3tools/contracts";
88import * as Schema from "effect/Schema";
9import { sanitizeNewRefName } from "@t3tools/shared/git";
910import { toSortableTimestamp } from "../lib/threadSort";
1011export {
1112 dedupeRemoteBranchesWithLocalMatches,
1213 deriveLocalBranchNameFromRemoteRef,
14 sanitizeNewRefName,
1315} from "@t3tools/shared/git";
1416
1517export interface EnvironmentOption {
......@@ -275,19 +277,6 @@ export function resolveBranchSelectionTarget(input: {
275277 };
276278}
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
291280export function shouldIncludeBranchPickerItem(input: {
292281 itemValue: string;
293282 normalizedQuery: string;
packages/shared/src/git.ts+13
......@@ -265,6 +265,19 @@ function deriveLocalBranchNameCandidatesFromRemoteRef(
265265 return [...candidates];
266266}
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
268281/**
269282 * Hide `origin/*` remote refs when a matching local refName already exists.
270283 */