From 99b0f5d72b78774df9396b2ba418cfd560fd9628 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Mar 2026 19:25:19 -0800 Subject: [PATCH 1/2] Virtualize branch picker list and forward ComboboxList ref - Add virtualized rendering and filtered item handling in `BranchToolbar` - Memoize branch-derived data and sync highlight scrolling for large branch sets - Convert `ComboboxList` to `forwardRef` to support virtualizer scroll targeting --- apps/web/src/components/BranchToolbar.tsx | 164 +++++++++++++++------- apps/web/src/components/ui/combobox.tsx | 31 ++-- 2 files changed, 128 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 11112e39476a..80b147d129ee 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,6 +1,7 @@ import type { GitBranch, ThreadId } from "@t3tools/contracts"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useOptimistic, useState, useTransition } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useMemo, useOptimistic, useRef, useState, useTransition } from "react"; import { newCommandId } from "../lib/utils"; import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery"; @@ -42,13 +43,13 @@ export default function BranchToolbar({ const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); const setThreadBranchAction = useStore((store) => store.setThreadBranch); - const setThreadErrorAction = useStore((store) => store.setError); const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); + const branchListRef = useRef(null); const serverThread = threads.find((thread) => thread.id === threadId); const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; @@ -67,24 +68,44 @@ export default function BranchToolbar({ const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); - const branches = dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []); + const branches = useMemo( + () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), + [branchesQuery.data?.branches], + ); const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; const canonicalActiveBranch = effectiveEnvMode === "worktree" && !activeWorktreePath ? activeThreadBranch : (currentGitBranch ?? activeThreadBranch); - const branchNames = branches.map((branch) => branch.name); - const branchByName = new Map(branches.map((branch) => [branch.name, branch])); + const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); + const branchByName = useMemo( + () => new Map(branches.map((branch) => [branch.name, branch] as const)), + [branches], + ); const trimmedBranchQuery = branchQuery.trim(); + const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); const createBranchItemValue = canCreateBranch ? `__create_new_branch__:${trimmedBranchQuery}` : null; - const branchPickerItems = - createBranchItemValue && !hasExactBranchMatch - ? [...branchNames, createBranchItemValue] - : branchNames; + const branchPickerItems = useMemo( + () => + createBranchItemValue && !hasExactBranchMatch + ? [...branchNames, createBranchItemValue] + : branchNames, + [branchNames, createBranchItemValue, hasExactBranchMatch], + ); + const filteredBranchPickerItems = useMemo( + () => + normalizedBranchQuery.length === 0 + ? branchPickerItems + : branchPickerItems.filter((itemValue) => { + if (createBranchItemValue && itemValue === createBranchItemValue) return true; + return itemValue.toLowerCase().includes(normalizedBranchQuery); + }), + [branchPickerItems, createBranchItemValue, normalizedBranchQuery], + ); // ── Helpers ─────────────────────────────────────────────────────────── @@ -100,10 +121,14 @@ export default function BranchToolbar({ }); }; - const setThreadError = (error: string | null) => { - if (!activeThreadId) return; - setThreadErrorAction(activeThreadId, error); - }; + const branchListVirtualizer = useVirtualizer({ + count: filteredBranchPickerItems.length, + estimateSize: () => 28, + getScrollElement: () => branchListRef.current?.parentElement ?? null, + overscan: 12, + enabled: isBranchMenuOpen, + }); + const virtualBranchRows = branchListVirtualizer.getVirtualItems(); const setThreadBranch = (branch: string | null, worktreePath: string | null) => { if (!activeThreadId) return; @@ -261,7 +286,13 @@ export default function BranchToolbar({ { + if (!isBranchMenuOpen || eventDetails.index < 0) return; + branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); + }} onOpenChange={(open) => { setIsBranchMenuOpen(open); if (!open) setBranchQuery(""); @@ -298,54 +329,81 @@ export default function BranchToolbar({ No branches found. - - {(itemValue) => { - if (createBranchItemValue && itemValue === createBranchItemValue) { + +
+ {virtualBranchRows.map((virtualRow) => { + const itemValue = filteredBranchPickerItems[virtualRow.index]; + if (!itemValue) return null; + 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 !== activeProject.cwd; + const badge = branch.current + ? "current" + : hasSecondaryWorktree + ? "worktree" + : branch.isRemote + ? "remote" + : branch.isDefault + ? "default" + : null; return ( createBranch(trimmedBranchQuery)} + className={ + itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined + } + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + transform: `translateY(${virtualRow.start}px)`, + }} + onClick={() => selectBranch(branch)} > - Create new branch "{trimmedBranchQuery}" +
+ {itemValue} + {badge && ( + + {badge} + + )} +
); - } - - const branch = branchByName.get(itemValue); - if (!branch) return null; - - const hasSecondaryWorktree = - branch.worktreePath && branch.worktreePath !== activeProject.cwd; - const badge = branch.current - ? "current" - : hasSecondaryWorktree - ? "worktree" - : branch.isRemote - ? "remote" - : branch.isDefault - ? "default" - : null; - return ( - selectBranch(branch)} - > -
- {itemValue} - {badge && ( - {badge} - )} -
-
- ); - }} + })} +
diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index b0927898217d..a949a270e77f 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -260,20 +260,23 @@ function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { return ; } -function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { - return ( - - - - ); -} +const ComboboxList = React.forwardRef( + function ComboboxList({ className, ...props }, ref) { + return ( + + + + ); + }, +); function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { return ; From 6105e44f3c9d303feace06754d9468db8effd61c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Mar 2026 19:27:32 -0800 Subject: [PATCH 2/2] no forwardref needed --- apps/web/src/components/ui/combobox.tsx | 31 +++++++++++-------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index a949a270e77f..b0927898217d 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -260,23 +260,20 @@ function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { return ; } -const ComboboxList = React.forwardRef( - function ComboboxList({ className, ...props }, ref) { - return ( - - - - ); - }, -); +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + + + ); +} function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { return ;