| author | |
| committer | |
| log | 84c436bcf714076572804d005eb7123ca1588188 |
| tree | 35a96158ce408dfc60529a30189f5e325b6aee67 |
| parent | ffb5fcce7336f9b3d5bde3a5f496a816907663af |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | filterNewTaskBranches, | |
| 4 | 5 | resolveNewTaskBranchWorktreePath, |
| 5 | 6 | resolveNewTaskBranchLabel, |
| 6 | 7 | resolveNewTaskLocalWorkspaceSelection, |
| ... | ... | @@ -126,3 +127,26 @@ describe("resolveNewTaskBranchLabel", () => { |
| 126 | 127 | ).toBe("Choose branch"); |
| 127 | 128 | }); |
| 128 | 129 | }); |
| 130 | ||
| 131 | describe("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 | import { sanitizeNewRefName } from "@t3tools/shared/git"; | |
| 2 | ||
| 1 | 3 | type WorkspaceMode = "local" | "worktree"; |
| 2 | 4 | |
| 3 | 5 | export function resolveNewTaskWorkspaceLabel(input: { |
| ... | ... | @@ -81,3 +83,13 @@ export function shouldCheckoutNewTaskBranch(input: { |
| 81 | 83 | }): boolean { |
| 82 | 84 | return input.workspaceMode === "local" && !input.branchIsCurrent && !input.branchWorktreePath; |
| 83 | 85 | } |
| 86 | ||
| 87 | export 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 | 18 | T3_PROJECT_FILE_NAME, |
| 19 | 19 | ThreadId, |
| 20 | 20 | } from "@t3tools/contracts"; |
| 21 | import { sanitizeNewRefName } from "@t3tools/shared/git"; | |
| 21 | 22 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 22 | 23 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; |
| 23 | 24 | import * as Arr from "effect/Array"; |
| ... | ... | @@ -92,6 +93,7 @@ import { |
| 92 | 93 | } from "../../state/legacy-plan-mode"; |
| 93 | 94 | import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; |
| 94 | 95 | import { |
| 96 | filterNewTaskBranches, | |
| 95 | 97 | resolveNewTaskBranchWorktreePath, |
| 96 | 98 | resolveNewTaskLocalWorkspaceSelection, |
| 97 | 99 | } from "./new-task-context-presentation"; |
| ... | ... | @@ -129,6 +131,9 @@ export function branchBadgeLabel(input: { |
| 129 | 131 | if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) { |
| 130 | 132 | return "worktree"; |
| 131 | 133 | } |
| 134 | if (input.branch.isRemote) { | |
| 135 | return "remote"; | |
| 136 | } | |
| 132 | 137 | if (input.branch.isDefault) { |
| 133 | 138 | return "default"; |
| 134 | 139 | } |
| ... | ... | @@ -626,7 +631,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 626 | 631 | } |
| 627 | 632 | replaceComposerDraftAttachments(selectedProjectDraftKey, []); |
| 628 | 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 | 636 | const branchTarget = useMemo( |
| 631 | 637 | () => ({ |
| 632 | 638 | environmentId: selectedProject?.environmentId ?? null, |
| ... | ... | @@ -637,7 +643,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 637 | 643 | [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot], |
| 638 | 644 | ); |
| 639 | 645 | const branchState = usePaginatedBranches(branchTarget); |
| 640 | const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim(); | |
| 646 | const branchSearchIsDebouncing = branchSearchQuery !== debouncedBranchQuery; | |
| 641 | 647 | const branchesLoading = |
| 642 | 648 | branchSearchIsDebouncing || (branchState.isPending && branchState.data === null); |
| 643 | 649 | const branchesFetchingNextPage = branchState.isFetchingNextPage; |
| ... | ... | @@ -669,17 +675,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 669 | 675 | ); |
| 670 | 676 | const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null; |
| 671 | 677 | |
| 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 | ); | |
| 683 | 682 | |
| 684 | 683 | // The composer's draft follows the project it will be sent to: switching |
| 685 | 684 | // 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 | 6 | WorktreeSubmodules, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 8 | import * as Schema from "effect/Schema"; |
| 9 | import { sanitizeNewRefName } from "@t3tools/shared/git"; | |
| 9 | 10 | import { toSortableTimestamp } from "../lib/threadSort"; |
| 10 | 11 | export { |
| 11 | 12 | dedupeRemoteBranchesWithLocalMatches, |
| 12 | 13 | deriveLocalBranchNameFromRemoteRef, |
| 14 | sanitizeNewRefName, | |
| 13 | 15 | } from "@t3tools/shared/git"; |
| 14 | 16 | |
| 15 | 17 | export interface EnvironmentOption { |
| ... | ... | @@ -275,19 +277,6 @@ export function resolveBranchSelectionTarget(input: { |
| 275 | 277 | }; |
| 276 | 278 | } |
| 277 | 279 | |
| 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. | |
| 287 | export function sanitizeNewRefName(rawName: string): string { | |
| 288 | return rawName.trim().replace(/[ \t\n\r\f\v]+/g, "-"); | |
| 289 | } | |
| 290 | ||
| 291 | 280 | export function shouldIncludeBranchPickerItem(input: { |
| 292 | 281 | itemValue: string; |
| 293 | 282 | normalizedQuery: string; |
packages/shared/src/git.ts+13| ... | ... | @@ -265,6 +265,19 @@ function deriveLocalBranchNameCandidatesFromRemoteRef( |
| 265 | 265 | return [...candidates]; |
| 266 | 266 | } |
| 267 | 267 | |
| 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. | |
| 277 | export function sanitizeNewRefName(rawName: string): string { | |
| 278 | return rawName.trim().replace(/[ \t\n\r\f\v]+/g, "-"); | |
| 279 | } | |
| 280 | ||
| 268 | 281 | /** |
| 269 | 282 | * Hide `origin/*` remote refs when a matching local refName already exists. |
| 270 | 283 | */ |