From 943ba0e1b403a927f2dc714adcc7efeea35291e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 9 Jun 2026 16:06:20 -0700 Subject: [PATCH 1/4] fix(web): polish branch picker trigger, scroll fade, and list layout Ported from #2451. Co-authored-by: ss Co-Authored-By: Claude Opus 4.8 --- .../BranchToolbarBranchSelector.tsx | 142 +++++++++++++----- 1 file changed, 106 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 152df1bf3e5..8750d5850c3 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,11 +1,12 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; import { useCallback, useDeferredValue, useEffect, + useLayoutEffect, useMemo, useOptimistic, useRef, @@ -421,7 +422,8 @@ export function BranchToolbarBranchSelector({ [branchRefTarget], ); - const branchListScrollElementRef = useRef(null); + const branchListScrollElementRef = useRef(null); + const [branchListBottomFadeVisible, setBranchListBottomFadeVisible] = useState(false); const fetchNextBranchPage = useCallback(() => { if (!hasNextPage || isFetchingNextPage) { return; @@ -451,11 +453,54 @@ export function BranchToolbarBranchSelector({ fetchNextBranchPage(); }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); + + const syncBranchListScrollChrome = useCallback((scrollEl: HTMLElement | null) => { + if (!scrollEl) { + setBranchListBottomFadeVisible(false); + return; + } + const { scrollTop, scrollHeight, clientHeight } = scrollEl; + const canScroll = scrollHeight > clientHeight + 1; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + setBranchListBottomFadeVisible(canScroll && distanceFromBottom > 6); + }, []); + const branchListRef = useRef(null); const setBranchListRef = useCallback((element: HTMLDivElement | null) => { - branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; + branchListScrollElementRef.current = element?.parentElement ?? null; }, []); + useEffect(() => { + if (isBranchMenuOpen) { + return; + } + setBranchListBottomFadeVisible(false); + }, [isBranchMenuOpen]); + + useLayoutEffect(() => { + if (!isBranchMenuOpen || !shouldVirtualizeBranchList) { + return; + } + + let frame = 0; + const measure = () => { + const el = branchListRef.current?.getScrollableNode?.(); + if (el instanceof HTMLElement) { + branchListScrollElementRef.current = el; + syncBranchListScrollChrome(el); + return; + } + frame = requestAnimationFrame(measure); + }; + frame = requestAnimationFrame(measure); + return () => cancelAnimationFrame(frame); + }, [ + isBranchMenuOpen, + shouldVirtualizeBranchList, + filteredBranchPickerItems.length, + syncBranchListScrollChrome, + ]); + useEffect(() => { if (!isBranchMenuOpen) { return; @@ -470,7 +515,7 @@ export function BranchToolbarBranchSelector({ useEffect(() => { const scrollElement = branchListScrollElementRef.current; - if (!scrollElement || !isBranchMenuOpen) { + if (!scrollElement || !isBranchMenuOpen || shouldVirtualizeBranchList) { return; } @@ -483,7 +528,7 @@ export function BranchToolbarBranchSelector({ return () => { scrollElement.removeEventListener("scroll", handleScroll); }; - }, [isBranchMenuOpen, maybeFetchNextBranchPage]); + }, [isBranchMenuOpen, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); useEffect(() => { if (shouldVirtualizeBranchList) return; @@ -504,6 +549,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} + className="pe-2" onClick={() => { if (!prReference || !onCheckoutPullRequestRequest) { return; @@ -562,8 +608,8 @@ export function BranchToolbarBranchSelector({ value={itemValue} onClick={() => selectBranch(refName)} > -
- {itemValue} +
+ {itemValue} {badge && {badge}}
@@ -594,11 +640,12 @@ export function BranchToolbarBranchSelector({ className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} disabled={isInitialBranchesLoadPending || isBranchActionPending} > + {triggerLabel} - + - -
+ +
setBranchQuery(event.target.value)} />
- No refs found. - - {shouldVirtualizeBranchList ? ( - - - ref={branchListRef} - data={filteredBranchPickerItems} - keyExtractor={(item) => item} - renderItem={({ item, index }) => renderPickerItem(item, index)} - estimatedItemSize={28} - drawDistance={336} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextBranchPage(); - } - }} - style={{ maxHeight: "14rem" }} - /> - - ) : ( - - {filteredBranchPickerItems.map((itemValue, index) => - renderPickerItem(itemValue, index), +
+ No refs found. +
+ {shouldVirtualizeBranchList ? ( + <> + + + ref={branchListRef} + data={filteredBranchPickerItems} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderPickerItem(item, index)} + estimatedItemSize={28} + drawDistance={336} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextBranchPage(); + } + }} + onScroll={() => { + const target = branchListRef.current?.getScrollableNode?.(); + if (target instanceof HTMLElement) { + branchListScrollElementRef.current = target; + syncBranchListScrollChrome(target); + } + maybeFetchNextBranchPage(); + }} + style={{ maxHeight: "14rem" }} + /> + +
+ + ) : ( + + {filteredBranchPickerItems.map((itemValue, index) => + renderPickerItem(itemValue, index), + )} + )} - - )} - {branchStatusText ? {branchStatusText} : null} +
+ {branchStatusText ? {branchStatusText} : null} +
); From e23b87619f00994e07f080a16650991a5c7e2f47 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 9 Jun 2026 23:45:06 -0700 Subject: [PATCH 2/4] Polish branch picker search field - Add search icon and unstyled input styling - Tighten popup spacing for virtualized and standard lists --- .../BranchToolbarBranchSelector.tsx | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 8750d5850c3..bfb920c5a00 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,7 +1,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; import { useCallback, useDeferredValue, @@ -645,23 +645,30 @@ export function BranchToolbarBranchSelector({ -
- setBranchQuery(event.target.value)} - /> +
+
+
No refs found.
{shouldVirtualizeBranchList ? ( <> - + ref={branchListRef} data={filteredBranchPickerItems} @@ -696,7 +703,7 @@ export function BranchToolbarBranchSelector({ ) : ( {filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index), From 5a9bcd2f891cd3b0d6190054422b90052beccbee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 10 Jun 2026 00:03:38 -0700 Subject: [PATCH 3/4] Unify branch picker on virtualized list - Always render the virtualized branch list - Update scroll fade handling and paging behavior --- .../BranchToolbarBranchSelector.tsx | 158 ++++++------------ 1 file changed, 55 insertions(+), 103 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index bfb920c5a00..b41c3942731 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -38,7 +38,6 @@ import { ComboboxEmpty, ComboboxInput, ComboboxItem, - ComboboxList, ComboboxListVirtualized, ComboboxPopup, ComboboxStatus, @@ -282,7 +281,6 @@ export function BranchToolbarBranchSelector({ (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, ); const [isBranchActionPending, startBranchActionTransition] = useTransition(); - const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const totalBranchCount = branchRefState.data?.totalCount ?? 0; const branchStatusText = isInitialBranchesLoadPending ? "Loading refs..." @@ -423,7 +421,8 @@ export function BranchToolbarBranchSelector({ ); const branchListScrollElementRef = useRef(null); - const [branchListBottomFadeVisible, setBranchListBottomFadeVisible] = useState(false); + const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false); + const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false); const fetchNextBranchPage = useCallback(() => { if (!hasNextPage || isFetchingNextPage) { return; @@ -454,86 +453,55 @@ export function BranchToolbarBranchSelector({ fetchNextBranchPage(); }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); - const syncBranchListScrollChrome = useCallback((scrollEl: HTMLElement | null) => { - if (!scrollEl) { - setBranchListBottomFadeVisible(false); + const branchListRef = useRef(null); + const updateBranchListScrollFades = useCallback(() => { + const scrollElement = branchListRef.current?.getScrollableNode?.(); + if (!(scrollElement instanceof HTMLElement)) { return; } - const { scrollTop, scrollHeight, clientHeight } = scrollEl; - const canScroll = scrollHeight > clientHeight + 1; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - setBranchListBottomFadeVisible(canScroll && distanceFromBottom > 6); - }, []); - - const branchListRef = useRef(null); - const setBranchListRef = useCallback((element: HTMLDivElement | null) => { - branchListScrollElementRef.current = element?.parentElement ?? null; + branchListScrollElementRef.current = scrollElement; + const maxScrollOffset = Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight); + setShowTopBranchScrollFade(scrollElement.scrollTop > 1); + setShowBottomBranchScrollFade(maxScrollOffset - scrollElement.scrollTop > 1); }, []); useEffect(() => { if (isBranchMenuOpen) { return; } - setBranchListBottomFadeVisible(false); + setShowTopBranchScrollFade(false); + setShowBottomBranchScrollFade(false); }, [isBranchMenuOpen]); useLayoutEffect(() => { - if (!isBranchMenuOpen || !shouldVirtualizeBranchList) { + if (!isBranchMenuOpen) { return; } - let frame = 0; - const measure = () => { - const el = branchListRef.current?.getScrollableNode?.(); - if (el instanceof HTMLElement) { - branchListScrollElementRef.current = el; - syncBranchListScrollChrome(el); - return; - } - frame = requestAnimationFrame(measure); + setShowTopBranchScrollFade(false); + setShowBottomBranchScrollFade(filteredBranchPickerItems.length > 8); + let nestedFrame = 0; + const frame = requestAnimationFrame(() => { + updateBranchListScrollFades(); + nestedFrame = requestAnimationFrame(updateBranchListScrollFades); + }); + return () => { + cancelAnimationFrame(frame); + cancelAnimationFrame(nestedFrame); }; - frame = requestAnimationFrame(measure); - return () => cancelAnimationFrame(frame); - }, [ - isBranchMenuOpen, - shouldVirtualizeBranchList, - filteredBranchPickerItems.length, - syncBranchListScrollChrome, - ]); + }, [filteredBranchPickerItems.length, isBranchMenuOpen, updateBranchListScrollFades]); useEffect(() => { if (!isBranchMenuOpen) { return; } - if (shouldVirtualizeBranchList) { - branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); - } else { - branchListScrollElementRef.current?.scrollTo({ top: 0 }); - } - }, [deferredTrimmedBranchQuery, isBranchMenuOpen, shouldVirtualizeBranchList]); - - useEffect(() => { - const scrollElement = branchListScrollElementRef.current; - if (!scrollElement || !isBranchMenuOpen || shouldVirtualizeBranchList) { - return; - } - - const handleScroll = () => { - maybeFetchNextBranchPage(); - }; - - scrollElement.addEventListener("scroll", handleScroll, { passive: true }); - handleScroll(); - return () => { - scrollElement.removeEventListener("scroll", handleScroll); - }; - }, [isBranchMenuOpen, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); + branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); + }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); useEffect(() => { - if (shouldVirtualizeBranchList) return; maybeFetchNextBranchPage(); - }, [refs.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); + }, [refs.length, maybeFetchNextBranchPage]); const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, @@ -579,6 +547,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} + className="pe-1.5" onClick={() => createRef(trimmedBranchQuery)} > Create new ref "{trimmedBranchQuery}" @@ -606,6 +575,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} + className="pe-1.5" onClick={() => selectBranch(refName)} >
@@ -621,7 +591,7 @@ export function BranchToolbarBranchSelector({ items={branchPickerItems} filteredItems={filteredBranchPickerItems} autoHighlight - virtualized={shouldVirtualizeBranchList} + virtualized onItemHighlighted={(_value, eventDetails) => { if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { return; @@ -666,50 +636,32 @@ export function BranchToolbarBranchSelector({
No refs found.
- {shouldVirtualizeBranchList ? ( - <> - - - ref={branchListRef} - data={filteredBranchPickerItems} - keyExtractor={(item) => item} - renderItem={({ item, index }) => renderPickerItem(item, index)} - estimatedItemSize={28} - drawDistance={336} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextBranchPage(); - } - }} - onScroll={() => { - const target = branchListRef.current?.getScrollableNode?.(); - if (target instanceof HTMLElement) { - branchListScrollElementRef.current = target; - syncBranchListScrollChrome(target); - } - maybeFetchNextBranchPage(); - }} - style={{ maxHeight: "14rem" }} - /> - -
- - ) : ( - - {filteredBranchPickerItems.map((itemValue, index) => - renderPickerItem(itemValue, index), + + + ref={branchListRef} + data={filteredBranchPickerItems} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderPickerItem(item, index)} + estimatedItemSize={28} + drawDistance={336} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextBranchPage(); + } + }} + onLayout={updateBranchListScrollFades} + onScroll={() => { + updateBranchListScrollFades(); + maybeFetchNextBranchPage(); + }} + className={cn( + "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1 [--fade-size:1.5rem]", + showTopBranchScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", + showBottomBranchScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", )} - - )} + style={{ maxHeight: "14rem" }} + /> +
{branchStatusText ? {branchStatusText} : null}
From a9cfbecac941db2db2f4bcb0f957beb473ecbcb4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 07:11:10 +0000 Subject: [PATCH 4/4] Fix branch picker pagination stuck on short lists and stale scroll fades after search - Add maybeFetchNextBranchPage() call in onLayout handler so pagination is checked when the list first renders and items don't fill the viewport - Add deferredTrimmedBranchQuery to useLayoutEffect dependency array so scroll fades are remeasured when search changes items without changing count Applied via @cursor push command --- .../src/components/BranchToolbarBranchSelector.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index b41c3942731..72391f714fc 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -489,7 +489,12 @@ export function BranchToolbarBranchSelector({ cancelAnimationFrame(frame); cancelAnimationFrame(nestedFrame); }; - }, [filteredBranchPickerItems.length, isBranchMenuOpen, updateBranchListScrollFades]); + }, [ + deferredTrimmedBranchQuery, + filteredBranchPickerItems.length, + isBranchMenuOpen, + updateBranchListScrollFades, + ]); useEffect(() => { if (!isBranchMenuOpen) { @@ -649,7 +654,10 @@ export function BranchToolbarBranchSelector({ fetchNextBranchPage(); } }} - onLayout={updateBranchListScrollFades} + onLayout={() => { + updateBranchListScrollFades(); + maybeFetchNextBranchPage(); + }} onScroll={() => { updateBranchListScrollFades(); maybeFetchNextBranchPage();