authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 11:56:11-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 22:50:45-07:00
logf70cb5de4517e55f62ed2c71e49291d97a7dc927
treedde4d5ad7b24bbcc43cc2325c09619711b0bbbf9
parent3d3586800ebfbefb97a4a782dae3439e90ba9065

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

4 files changed, 206 insertions(+), 125 deletions(-)

apps/web/src/components/BranchToolbar.logic.test.ts+29
......@@ -1,6 +1,7 @@
11import type { GitBranch } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33import {
4 filterBranchPickerItems,
45 dedupeRemoteBranchesWithLocalMatches,
56 deriveLocalBranchNameFromRemoteRef,
67 resolveDraftEnvModeAfterBranchChange,
......@@ -194,3 +195,31 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
194195 ]);
195196 });
196197});
198
199describe("filterBranchPickerItems", () => {
200 it("shows only the pull request action when the query is a PR reference", () => {
201 expect(
202 filterBranchPickerItems({
203 itemValues: [
204 "__checkout_pull_request__:488",
205 "codething/76f09488",
206 "origin/cursor/rollback-error",
207 ],
208 normalizedQuery: "488",
209 createBranchItemValue: null,
210 checkoutPullRequestItemValue: "__checkout_pull_request__:488",
211 }),
212 ).toEqual(["__checkout_pull_request__:488"]);
213 });
214
215 it("filters branch items normally when the query is not a PR reference", () => {
216 expect(
217 filterBranchPickerItems({
218 itemValues: ["feature/488", "main", "__create_new_branch__:48"],
219 normalizedQuery: "48",
220 createBranchItemValue: "__create_new_branch__:48",
221 checkoutPullRequestItemValue: null,
222 }),
223 ).toEqual(["feature/488", "__create_new_branch__:48"]);
224 });
225});
apps/web/src/components/BranchToolbar.logic.ts+24
......@@ -88,3 +88,27 @@ export function dedupeRemoteBranchesWithLocalMatches(
8888 return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
8989 });
9090}
91
92export function filterBranchPickerItems(input: {
93 itemValues: ReadonlyArray<string>;
94 normalizedQuery: string;
95 createBranchItemValue: string | null;
96 checkoutPullRequestItemValue: string | null;
97}): ReadonlyArray<string> {
98 const { itemValues, normalizedQuery, createBranchItemValue, checkoutPullRequestItemValue } = input;
99
100 if (checkoutPullRequestItemValue) {
101 return [checkoutPullRequestItemValue];
102 }
103
104 if (normalizedQuery.length === 0) {
105 return itemValues;
106 }
107
108 return itemValues.filter((itemValue) => {
109 if (createBranchItemValue && itemValue === createBranchItemValue) {
110 return true;
111 }
112 return itemValue.toLowerCase().includes(normalizedQuery);
113 });
114}
apps/web/src/components/BranchToolbarBranchSelector.tsx+138-120
......@@ -3,7 +3,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
33import { useVirtualizer } from "@tanstack/react-virtual";
44import { ChevronDownIcon } from "lucide-react";
55import {
6 type CSSProperties,
67 useCallback,
8 useDeferredValue,
79 useEffect,
810 useMemo,
911 useOptimistic,
......@@ -12,13 +14,19 @@ import {
1214 useTransition,
1315} from "react";
1416
15import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery";
17import {
18 gitBranchesQueryOptions,
19 gitQueryKeys,
20 gitStatusQueryOptions,
21 invalidateGitQueries,
22} from "../lib/gitReactQuery";
1623import { readNativeApi } from "../nativeApi";
1724import { parsePullRequestReference } from "../pullRequestReference";
1825import {
1926 dedupeRemoteBranchesWithLocalMatches,
2027 deriveLocalBranchNameFromRemoteRef,
2128 EnvMode,
29 filterBranchPickerItems,
2230 resolveBranchToolbarValue,
2331} from "./BranchToolbar.logic";
2432import { Button } from "./ui/button";
......@@ -78,13 +86,16 @@ export function BranchToolbarBranchSelector({
7886 const queryClient = useQueryClient();
7987 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
8088 const [branchQuery, setBranchQuery] = useState("");
89 const deferredBranchQuery = useDeferredValue(branchQuery);
8190
8291 const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
92 const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd));
8393 const branches = useMemo(
8494 () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []),
8595 [branchesQuery.data?.branches],
8696 );
87 const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null;
97 const currentGitBranch =
98 branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null;
8899 const canonicalActiveBranch = resolveBranchToolbarValue({
89100 envMode: effectiveEnvMode,
90101 activeWorktreePath,
......@@ -97,12 +108,15 @@ export function BranchToolbarBranchSelector({
97108 [branches],
98109 );
99110 const trimmedBranchQuery = branchQuery.trim();
100 const normalizedBranchQuery = trimmedBranchQuery.toLowerCase();
111 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
112 const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase();
101113 const prReference = parsePullRequestReference(trimmedBranchQuery);
114 const isSelectingWorktreeBase =
115 effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath;
102116 const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest
103117 ? `__checkout_pull_request__:${prReference}`
104118 : null;
105 const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0;
119 const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0;
106120 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);
107121 const createBranchItemValue = canCreateBranch
108122 ? `__create_new_branch__:${trimmedBranchQuery}`
......@@ -122,19 +136,25 @@ export function BranchToolbarBranchSelector({
122136 );
123137 const filteredBranchPickerItems = useMemo(
124138 () =>
125 normalizedBranchQuery.length === 0
126 ? branchPickerItems
127 : branchPickerItems.filter((itemValue) => {
128 if (createBranchItemValue && itemValue === createBranchItemValue) return true;
129 return itemValue.toLowerCase().includes(normalizedBranchQuery);
130 }),
131 [branchPickerItems, createBranchItemValue, normalizedBranchQuery],
139 filterBranchPickerItems({
140 itemValues: branchPickerItems,
141 normalizedQuery: normalizedDeferredBranchQuery,
142 createBranchItemValue,
143 checkoutPullRequestItemValue,
144 }),
145 [
146 branchPickerItems,
147 checkoutPullRequestItemValue,
148 createBranchItemValue,
149 normalizedDeferredBranchQuery,
150 ],
132151 );
133152 const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic(
134153 canonicalActiveBranch,
135154 (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch,
136155 );
137156 const [isBranchActionPending, startBranchActionTransition] = useTransition();
157 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
138158
139159 const runBranchAction = (action: () => Promise<void>) => {
140160 startBranchActionTransition(async () => {
......@@ -148,7 +168,7 @@ export function BranchToolbarBranchSelector({
148168 if (!api || !branchCwd || isBranchActionPending) return;
149169
150170 // In new-worktree mode, selecting a branch sets the base branch.
151 if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) {
171 if (isSelectingWorktreeBase) {
152172 onSetThreadBranch(branch.name, null);
153173 setIsBranchMenuOpen(false);
154174 onComposerFocusRequest?.();
......@@ -274,7 +294,7 @@ export function BranchToolbarBranchSelector({
274294 estimateSize: () => 28,
275295 getScrollElement: () => branchListScrollElementRef.current,
276296 overscan: 12,
277 enabled: isBranchMenuOpen,
297 enabled: isBranchMenuOpen && shouldVirtualizeBranchList,
278298 initialRect: {
279299 height: 224,
280300 width: 0,
......@@ -293,11 +313,16 @@ export function BranchToolbarBranchSelector({
293313 );
294314
295315 useEffect(() => {
296 if (!isBranchMenuOpen) return;
316 if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return;
297317 queueMicrotask(() => {
298318 branchListVirtualizer.measure();
299319 });
300 }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]);
320 }, [
321 branchListVirtualizer,
322 filteredBranchPickerItems.length,
323 isBranchMenuOpen,
324 shouldVirtualizeBranchList,
325 ]);
301326
302327 const triggerLabel = getBranchTriggerLabel({
303328 activeWorktreePath,
......@@ -305,6 +330,80 @@ export function BranchToolbarBranchSelector({
305330 resolvedActiveBranch,
306331 });
307332
333 function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) {
334 if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
335 return (
336 <ComboboxItem
337 hideIndicator
338 key={itemValue}
339 index={index}
340 value={itemValue}
341 style={style}
342 onClick={() => {
343 if (!prReference || !onCheckoutPullRequestRequest) {
344 return;
345 }
346 setIsBranchMenuOpen(false);
347 setBranchQuery("");
348 onComposerFocusRequest?.();
349 onCheckoutPullRequestRequest(prReference);
350 }}
351 >
352 <div className="flex min-w-0 flex-col items-start py-1">
353 <span className="truncate font-medium">Checkout Pull Request</span>
354 <span className="truncate text-muted-foreground text-xs">{prReference}</span>
355 </div>
356 </ComboboxItem>
357 );
358 }
359 if (createBranchItemValue && itemValue === createBranchItemValue) {
360 return (
361 <ComboboxItem
362 hideIndicator
363 key={itemValue}
364 index={index}
365 value={itemValue}
366 style={style}
367 onClick={() => createBranch(trimmedBranchQuery)}
368 >
369 <span className="truncate">Create new branch "{trimmedBranchQuery}"</span>
370 </ComboboxItem>
371 );
372 }
373
374 const branch = branchByName.get(itemValue);
375 if (!branch) return null;
376
377 const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProjectCwd;
378 const badge = branch.current
379 ? "current"
380 : hasSecondaryWorktree
381 ? "worktree"
382 : branch.isRemote
383 ? "remote"
384 : branch.isDefault
385 ? "default"
386 : null;
387 return (
388 <ComboboxItem
389 hideIndicator
390 key={itemValue}
391 index={index}
392 value={itemValue}
393 className={itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined}
394 style={style}
395 onClick={() => selectBranch(branch)}
396 >
397 <div className="flex w-full items-center justify-between gap-2">
398 <span className="truncate">{itemValue}</span>
399 {badge && (
400 <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>
401 )}
402 </div>
403 </ComboboxItem>
404 );
405 }
406
308407 return (
309408 <Combobox
310409 items={branchPickerItems}
......@@ -322,12 +421,12 @@ export function BranchToolbarBranchSelector({
322421 <ComboboxTrigger
323422 render={<Button variant="ghost" size="xs" />}
324423 className="text-muted-foreground/70 hover:text-foreground/80"
325 disabled={branchesQuery.isLoading || isBranchActionPending}
424 disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending}
326425 >
327426 <span className="max-w-[240px] truncate">{triggerLabel}</span>
328427 <ChevronDownIcon />
329428 </ComboboxTrigger>
330 <ComboboxPopup align="end" side="top" className="w-64">
429 <ComboboxPopup align="end" side="top" className="w-80">
331430 <div className="border-b p-1">
332431 <ComboboxInput
333432 className="[&_input]:font-sans rounded-md"
......@@ -342,109 +441,28 @@ export function BranchToolbarBranchSelector({
342441 <ComboboxEmpty>No branches found.</ComboboxEmpty>
343442
344443 <ComboboxList ref={setBranchListRef} className="max-h-56">
345 <div
346 className="relative"
347 style={{
348 height: `${branchListVirtualizer.getTotalSize()}px`,
349 }}
350 >
351 {virtualBranchRows.map((virtualRow) => {
352 const itemValue = filteredBranchPickerItems[virtualRow.index];
353 if (!itemValue) return null;
354 if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
355 return (
356 <ComboboxItem
357 hideIndicator
358 key={itemValue}
359 index={virtualRow.index}
360 value={itemValue}
361 style={{
362 position: "absolute",
363 top: 0,
364 left: 0,
365 width: "100%",
366 transform: `translateY(${virtualRow.start}px)`,
367 }}
368 onClick={() => {
369 if (!prReference || !onCheckoutPullRequestRequest) {
370 return;
371 }
372 setIsBranchMenuOpen(false);
373 setBranchQuery("");
374 onComposerFocusRequest?.();
375 onCheckoutPullRequestRequest(prReference);
376 }}
377 >
378 <div className="flex min-w-0 flex-col items-start">
379 <span className="truncate font-medium">Checkout Pull Request</span>
380 <span className="truncate text-muted-foreground text-xs">{prReference}</span>
381 </div>
382 </ComboboxItem>
383 );
384 }
385 if (createBranchItemValue && itemValue === createBranchItemValue) {
386 return (
387 <ComboboxItem
388 hideIndicator
389 key={itemValue}
390 index={virtualRow.index}
391 value={itemValue}
392 style={{
393 position: "absolute",
394 top: 0,
395 left: 0,
396 width: "100%",
397 transform: `translateY(${virtualRow.start}px)`,
398 }}
399 onClick={() => createBranch(trimmedBranchQuery)}
400 >
401 <span className="truncate">Create new branch "{trimmedBranchQuery}"</span>
402 </ComboboxItem>
403 );
404 }
405
406 const branch = branchByName.get(itemValue);
407 if (!branch) return null;
408
409 const hasSecondaryWorktree =
410 branch.worktreePath && branch.worktreePath !== activeProjectCwd;
411 const badge = branch.current
412 ? "current"
413 : hasSecondaryWorktree
414 ? "worktree"
415 : branch.isRemote
416 ? "remote"
417 : branch.isDefault
418 ? "default"
419 : null;
420 return (
421 <ComboboxItem
422 hideIndicator
423 key={itemValue}
424 index={virtualRow.index}
425 value={itemValue}
426 className={
427 itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined
428 }
429 style={{
430 position: "absolute",
431 top: 0,
432 left: 0,
433 width: "100%",
434 transform: `translateY(${virtualRow.start}px)`,
435 }}
436 onClick={() => selectBranch(branch)}
437 >
438 <div className="flex w-full items-center justify-between gap-2">
439 <span className="truncate">{itemValue}</span>
440 {badge && (
441 <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>
442 )}
443 </div>
444 </ComboboxItem>
445 );
446 })}
447 </div>
444 {shouldVirtualizeBranchList ? (
445 <div
446 className="relative"
447 style={{
448 height: `${branchListVirtualizer.getTotalSize()}px`,
449 }}
450 >
451 {virtualBranchRows.map((virtualRow) => {
452 const itemValue = filteredBranchPickerItems[virtualRow.index];
453 if (!itemValue) return null;
454 return renderPickerItem(itemValue, virtualRow.index, {
455 position: "absolute",
456 top: 0,
457 left: 0,
458 width: "100%",
459 transform: `translateY(${virtualRow.start}px)`,
460 });
461 })}
462 </div>
463 ) : (
464 filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index))
465 )}
448466 </ComboboxList>
449467 </ComboboxPopup>
450468 </Combobox>
apps/web/src/components/ChatView.tsx+15-5
......@@ -757,6 +757,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
757757 composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
758758 const isServerThread = serverThread !== undefined;
759759 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
760 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
760761 const diffOpen = rawSearch.diff === "1";
761762 const activeThreadId = activeThread?.id ?? null;
762763 const activeLatestTurn = activeThread?.latestTurn ?? null;
......@@ -764,12 +765,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
764765 const activeProject = projects.find((p) => p.id === activeThread?.projectId);
765766
766767 const openPullRequestComposerMenu = useCallback((reference?: string) => {
768 if (!canCheckoutPullRequestIntoThread) {
769 return;
770 }
767771 setPullRequestComposerMenuState({
768772 initialReference: reference ?? null,
769773 key: Date.now(),
770774 });
771775 setComposerHighlightedItemId(null);
772 }, []);
776 }, [canCheckoutPullRequestIntoThread]);
773777
774778 const closePullRequestComposerMenu = useCallback(() => {
775779 setPullRequestComposerMenuState(null);
......@@ -1315,11 +1319,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
13151319 description: "Create a new thread from a GitHub pull request",
13161320 },
13171321 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
1322 const availableSlashCommandItems = canCheckoutPullRequestIntoThread
1323 ? slashCommandItems
1324 : slashCommandItems.filter((item) => item.command !== "checkout-pr");
13181325 const query = composerTrigger.query.trim().toLowerCase();
13191326 if (!query) {
1320 return [...slashCommandItems];
1327 return [...availableSlashCommandItems];
13211328 }
1322 return slashCommandItems.filter(
1329 return availableSlashCommandItems.filter(
13231330 (item) => item.command.includes(query) || item.label.slice(1).includes(query),
13241331 );
13251332 }
......@@ -1340,7 +1347,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13401347 label: name,
13411348 description: `${providerLabel} · ${slug}`,
13421349 }));
1343 }, [composerTrigger, searchableModelOptions, workspaceEntries]);
1350 }, [canCheckoutPullRequestIntoThread, composerTrigger, searchableModelOptions, workspaceEntries]);
13441351 const composerMenuOpen = Boolean(composerTrigger) || pullRequestComposerMenuOpen;
13451352 const activeComposerMenuItem = useMemo(
13461353 () =>
......@@ -2582,7 +2589,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
25822589 }
25832590 const standaloneSlashCommand =
25842591 composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null;
2585 if (standaloneSlashCommand) {
2592 const shouldHandleStandaloneSlashCommand =
2593 standaloneSlashCommand !== null &&
2594 (standaloneSlashCommand !== "checkout-pr" || canCheckoutPullRequestIntoThread);
2595 if (shouldHandleStandaloneSlashCommand && standaloneSlashCommand) {
25862596 if (standaloneSlashCommand === "checkout-pr") {
25872597 openPullRequestComposerMenu();
25882598 } else {