From f70cb5de4517e55f62ed2c71e49291d97a7dc927 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Mar 2026 11:56:11 -0700 Subject: [PATCH] Refine branch picker behavior and gate /checkout-pr by thread type - Add shared branch picker filtering logic and tests, including PR-reference-only results - Improve branch selector UX/perf with deferred query filtering, conditional virtualization, and status-based current branch resolution - Only expose and handle `/checkout-pr` for local draft threads in chat and toolbar UI --- .../components/BranchToolbar.logic.test.ts | 29 ++ .../web/src/components/BranchToolbar.logic.ts | 24 ++ .../BranchToolbarBranchSelector.tsx | 258 ++++++++++-------- apps/web/src/components/ChatView.tsx | 20 +- 4 files changed, 206 insertions(+), 125 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index ea164b363048a02a16f0e2d76434b96814b4b8c6..492f080beaaa29e2ec7f5dbbbd601286ea4ded29 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,6 +1,7 @@ import type { GitBranch } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { + filterBranchPickerItems, dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, resolveDraftEnvModeAfterBranchChange, @@ -194,3 +195,31 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { ]); }); }); + +describe("filterBranchPickerItems", () => { + it("shows only the pull request action when the query is a PR reference", () => { + expect( + filterBranchPickerItems({ + itemValues: [ + "__checkout_pull_request__:488", + "codething/76f09488", + "origin/cursor/rollback-error", + ], + normalizedQuery: "488", + createBranchItemValue: null, + checkoutPullRequestItemValue: "__checkout_pull_request__:488", + }), + ).toEqual(["__checkout_pull_request__:488"]); + }); + + it("filters branch items normally when the query is not a PR reference", () => { + expect( + filterBranchPickerItems({ + itemValues: ["feature/488", "main", "__create_new_branch__:48"], + normalizedQuery: "48", + createBranchItemValue: "__create_new_branch__:48", + checkoutPullRequestItemValue: null, + }), + ).toEqual(["feature/488", "__create_new_branch__:48"]); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 1b48970bbfc0cc5826e54560080c23c346755eea..3db270eeb2852354f38924e5cbad963d36b659ee 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -88,3 +88,27 @@ export function dedupeRemoteBranchesWithLocalMatches( return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); }); } + +export function filterBranchPickerItems(input: { + itemValues: ReadonlyArray; + normalizedQuery: string; + createBranchItemValue: string | null; + checkoutPullRequestItemValue: string | null; +}): ReadonlyArray { + const { itemValues, normalizedQuery, createBranchItemValue, checkoutPullRequestItemValue } = input; + + if (checkoutPullRequestItemValue) { + return [checkoutPullRequestItemValue]; + } + + if (normalizedQuery.length === 0) { + return itemValues; + } + + return itemValues.filter((itemValue) => { + if (createBranchItemValue && itemValue === createBranchItemValue) { + return true; + } + return itemValue.toLowerCase().includes(normalizedQuery); + }); +} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 249d9b45a29901d2cda4b1245eb7d815d4237c38..3869ec234a4c49b8cc6ff5c2a178b88ecb6d2120 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -3,7 +3,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDownIcon } from "lucide-react"; import { + type CSSProperties, useCallback, + useDeferredValue, useEffect, useMemo, useOptimistic, @@ -12,13 +14,19 @@ import { useTransition, } from "react"; -import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery"; +import { + gitBranchesQueryOptions, + gitQueryKeys, + gitStatusQueryOptions, + invalidateGitQueries, +} from "../lib/gitReactQuery"; import { readNativeApi } from "../nativeApi"; import { parsePullRequestReference } from "../pullRequestReference"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, EnvMode, + filterBranchPickerItems, resolveBranchToolbarValue, } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; @@ -78,13 +86,16 @@ export function BranchToolbarBranchSelector({ const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); + const deferredBranchQuery = useDeferredValue(branchQuery); const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); + const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); const branches = useMemo( () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), [branchesQuery.data?.branches], ); - const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; + const currentGitBranch = + branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; const canonicalActiveBranch = resolveBranchToolbarValue({ envMode: effectiveEnvMode, activeWorktreePath, @@ -97,12 +108,15 @@ export function BranchToolbarBranchSelector({ [branches], ); const trimmedBranchQuery = branchQuery.trim(); - const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); + const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); + const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); + const isSelectingWorktreeBase = + effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; - const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; + const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); const createBranchItemValue = canCreateBranch ? `__create_new_branch__:${trimmedBranchQuery}` @@ -122,19 +136,25 @@ export function BranchToolbarBranchSelector({ ); const filteredBranchPickerItems = useMemo( () => - normalizedBranchQuery.length === 0 - ? branchPickerItems - : branchPickerItems.filter((itemValue) => { - if (createBranchItemValue && itemValue === createBranchItemValue) return true; - return itemValue.toLowerCase().includes(normalizedBranchQuery); - }), - [branchPickerItems, createBranchItemValue, normalizedBranchQuery], + filterBranchPickerItems({ + itemValues: branchPickerItems, + normalizedQuery: normalizedDeferredBranchQuery, + createBranchItemValue, + checkoutPullRequestItemValue, + }), + [ + branchPickerItems, + checkoutPullRequestItemValue, + createBranchItemValue, + normalizedDeferredBranchQuery, + ], ); const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( canonicalActiveBranch, (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, ); const [isBranchActionPending, startBranchActionTransition] = useTransition(); + const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { @@ -148,7 +168,7 @@ export function BranchToolbarBranchSelector({ if (!api || !branchCwd || isBranchActionPending) return; // In new-worktree mode, selecting a branch sets the base branch. - if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) { + if (isSelectingWorktreeBase) { onSetThreadBranch(branch.name, null); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); @@ -274,7 +294,7 @@ export function BranchToolbarBranchSelector({ estimateSize: () => 28, getScrollElement: () => branchListScrollElementRef.current, overscan: 12, - enabled: isBranchMenuOpen, + enabled: isBranchMenuOpen && shouldVirtualizeBranchList, initialRect: { height: 224, width: 0, @@ -293,11 +313,16 @@ export function BranchToolbarBranchSelector({ ); useEffect(() => { - if (!isBranchMenuOpen) return; + if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return; queueMicrotask(() => { branchListVirtualizer.measure(); }); - }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]); + }, [ + branchListVirtualizer, + filteredBranchPickerItems.length, + isBranchMenuOpen, + shouldVirtualizeBranchList, + ]); const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, @@ -305,6 +330,80 @@ export function BranchToolbarBranchSelector({ resolvedActiveBranch, }); + function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) { + if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { + return ( + { + if (!prReference || !onCheckoutPullRequestRequest) { + return; + } + setIsBranchMenuOpen(false); + setBranchQuery(""); + onComposerFocusRequest?.(); + onCheckoutPullRequestRequest(prReference); + }} + > +
+ Checkout Pull Request + {prReference} +
+
+ ); + } + if (createBranchItemValue && itemValue === createBranchItemValue) { + return ( + createBranch(trimmedBranchQuery)} + > + Create new branch "{trimmedBranchQuery}" + + ); + } + + const branch = branchByName.get(itemValue); + if (!branch) return null; + + const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProjectCwd; + const badge = branch.current + ? "current" + : hasSecondaryWorktree + ? "worktree" + : branch.isRemote + ? "remote" + : branch.isDefault + ? "default" + : null; + return ( + selectBranch(branch)} + > +
+ {itemValue} + {badge && ( + {badge} + )} +
+
+ ); + } + return ( } className="text-muted-foreground/70 hover:text-foreground/80" - disabled={branchesQuery.isLoading || isBranchActionPending} + disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending} > {triggerLabel} - +
No branches found. -
- {virtualBranchRows.map((virtualRow) => { - const itemValue = filteredBranchPickerItems[virtualRow.index]; - if (!itemValue) return null; - if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { - return ( - { - if (!prReference || !onCheckoutPullRequestRequest) { - return; - } - setIsBranchMenuOpen(false); - setBranchQuery(""); - onComposerFocusRequest?.(); - onCheckoutPullRequestRequest(prReference); - }} - > -
- Checkout Pull Request - {prReference} -
-
- ); - } - if (createBranchItemValue && itemValue === createBranchItemValue) { - return ( - createBranch(trimmedBranchQuery)} - > - Create new branch "{trimmedBranchQuery}" - - ); - } - - const branch = branchByName.get(itemValue); - if (!branch) return null; - - const hasSecondaryWorktree = - branch.worktreePath && branch.worktreePath !== activeProjectCwd; - const badge = branch.current - ? "current" - : hasSecondaryWorktree - ? "worktree" - : branch.isRemote - ? "remote" - : branch.isDefault - ? "default" - : null; - return ( - selectBranch(branch)} - > -
- {itemValue} - {badge && ( - {badge} - )} -
-
- ); - })} -
+ {shouldVirtualizeBranchList ? ( +
+ {virtualBranchRows.map((virtualRow) => { + const itemValue = filteredBranchPickerItems[virtualRow.index]; + if (!itemValue) return null; + return renderPickerItem(itemValue, virtualRow.index, { + position: "absolute", + top: 0, + left: 0, + width: "100%", + transform: `translateY(${virtualRow.start}px)`, + }); + })} +
+ ) : ( + filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) + )}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 63abe619a42dc646c3001b18d93a3d10dd9de43a..80d440994458d0d9e1d46dc45b6dd7b2f1ff3bcd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -757,6 +757,7 @@ export default function ChatView({ threadId }: ChatViewProps) { composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; + const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; @@ -764,12 +765,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const activeProject = projects.find((p) => p.id === activeThread?.projectId); const openPullRequestComposerMenu = useCallback((reference?: string) => { + if (!canCheckoutPullRequestIntoThread) { + return; + } setPullRequestComposerMenuState({ initialReference: reference ?? null, key: Date.now(), }); setComposerHighlightedItemId(null); - }, []); + }, [canCheckoutPullRequestIntoThread]); const closePullRequestComposerMenu = useCallback(() => { setPullRequestComposerMenuState(null); @@ -1315,11 +1319,14 @@ export default function ChatView({ threadId }: ChatViewProps) { description: "Create a new thread from a GitHub pull request", }, ] satisfies ReadonlyArray>; + const availableSlashCommandItems = canCheckoutPullRequestIntoThread + ? slashCommandItems + : slashCommandItems.filter((item) => item.command !== "checkout-pr"); const query = composerTrigger.query.trim().toLowerCase(); if (!query) { - return [...slashCommandItems]; + return [...availableSlashCommandItems]; } - return slashCommandItems.filter( + return availableSlashCommandItems.filter( (item) => item.command.includes(query) || item.label.slice(1).includes(query), ); } @@ -1340,7 +1347,7 @@ export default function ChatView({ threadId }: ChatViewProps) { label: name, description: `${providerLabel} ยท ${slug}`, })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); + }, [canCheckoutPullRequestIntoThread, composerTrigger, searchableModelOptions, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger) || pullRequestComposerMenuOpen; const activeComposerMenuItem = useMemo( () => @@ -2582,7 +2589,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } const standaloneSlashCommand = composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; - if (standaloneSlashCommand) { + const shouldHandleStandaloneSlashCommand = + standaloneSlashCommand !== null && + (standaloneSlashCommand !== "checkout-pr" || canCheckoutPullRequestIntoThread); + if (shouldHandleStandaloneSlashCommand && standaloneSlashCommand) { if (standaloneSlashCommand === "checkout-pr") { openPullRequestComposerMenu(); } else { -- 2.54.0