Skip to content

Commit 96c9306

Browse files
Migrate chat scrolling and branch lists to LegendList (#1953)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 94d13a2 commit 96c9306

15 files changed

+895
-2672
lines changed

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@dnd-kit/utilities": "^3.2.2",
2222
"@effect/atom-react": "catalog:",
2323
"@formkit/auto-animate": "^0.9.0",
24+
"@legendapp/list": "3.0.0-beta.44",
2425
"@lexical/react": "^0.41.0",
2526
"@pierre/diffs": "^1.1.0-beta.16",
2627
"@t3tools/client-runtime": "workspace:*",
@@ -29,7 +30,6 @@
2930
"@tanstack/react-pacer": "^0.19.4",
3031
"@tanstack/react-query": "^5.90.0",
3132
"@tanstack/react-router": "^1.160.2",
32-
"@tanstack/react-virtual": "^3.13.18",
3333
"@xterm/addon-fit": "^0.11.0",
3434
"@xterm/xterm": "^6.0.0",
3535
"class-variance-authority": "^0.7.1",

apps/web/src/chat-scroll.test.ts

Lines changed: 0 additions & 62 deletions
This file was deleted.

apps/web/src/chat-scroll.ts

Lines changed: 0 additions & 24 deletions
This file was deleted.

apps/web/src/components/BranchToolbarBranchSelector.tsx

Lines changed: 46 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
22
import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts";
33
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
4-
import { useVirtualizer } from "@tanstack/react-virtual";
4+
import { LegendList, type LegendListRef } from "@legendapp/list/react";
55
import { ChevronDownIcon } from "lucide-react";
66
import {
7-
type CSSProperties,
87
useCallback,
98
useDeferredValue,
109
useEffect,
@@ -38,6 +37,7 @@ import {
3837
ComboboxInput,
3938
ComboboxItem,
4039
ComboboxList,
40+
ComboboxListVirtualized,
4141
ComboboxPopup,
4242
ComboboxStatus,
4343
ComboboxTrigger,
@@ -390,7 +390,7 @@ export function BranchToolbarBranchSelector({
390390
}, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]);
391391

392392
// ---------------------------------------------------------------------------
393-
// Combobox / virtualizer plumbing
393+
// Combobox / list plumbing
394394
// ---------------------------------------------------------------------------
395395
const handleOpenChange = useCallback(
396396
(open: boolean) => {
@@ -425,49 +425,22 @@ export function BranchToolbarBranchSelector({
425425

426426
void fetchNextPage().catch(() => undefined);
427427
}, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);
428-
const branchListVirtualizer = useVirtualizer({
429-
count: filteredBranchPickerItems.length,
430-
estimateSize: (index) =>
431-
filteredBranchPickerItems[index] === checkoutPullRequestItemValue ? 44 : 28,
432-
getScrollElement: () => branchListScrollElementRef.current,
433-
overscan: 12,
434-
enabled: isBranchMenuOpen && shouldVirtualizeBranchList,
435-
initialRect: {
436-
height: 224,
437-
width: 0,
438-
},
439-
});
440-
const virtualBranchRows = branchListVirtualizer.getVirtualItems();
441-
const setBranchListRef = useCallback(
442-
(element: HTMLDivElement | null) => {
443-
branchListScrollElementRef.current =
444-
(element?.parentElement as HTMLDivElement | null) ?? null;
445-
if (element) {
446-
branchListVirtualizer.measure();
447-
}
448-
},
449-
[branchListVirtualizer],
450-
);
451-
452-
useEffect(() => {
453-
if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return;
454-
queueMicrotask(() => {
455-
branchListVirtualizer.measure();
456-
});
457-
}, [
458-
branchListVirtualizer,
459-
filteredBranchPickerItems.length,
460-
isBranchMenuOpen,
461-
shouldVirtualizeBranchList,
462-
]);
428+
const branchListRef = useRef<LegendListRef | null>(null);
429+
const setBranchListRef = useCallback((element: HTMLDivElement | null) => {
430+
branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null;
431+
}, []);
463432

464433
useEffect(() => {
465434
if (!isBranchMenuOpen) {
466435
return;
467436
}
468437

469-
branchListScrollElementRef.current?.scrollTo({ top: 0 });
470-
}, [deferredTrimmedBranchQuery, isBranchMenuOpen]);
438+
if (shouldVirtualizeBranchList) {
439+
branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false });
440+
} else {
441+
branchListScrollElementRef.current?.scrollTo({ top: 0 });
442+
}
443+
}, [deferredTrimmedBranchQuery, isBranchMenuOpen, shouldVirtualizeBranchList]);
471444

472445
useEffect(() => {
473446
const scrollElement = branchListScrollElementRef.current;
@@ -487,24 +460,24 @@ export function BranchToolbarBranchSelector({
487460
}, [isBranchMenuOpen, maybeFetchNextBranchPage]);
488461

489462
useEffect(() => {
463+
if (shouldVirtualizeBranchList) return;
490464
maybeFetchNextBranchPage();
491-
}, [branches.length, maybeFetchNextBranchPage]);
465+
}, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]);
492466

493467
const triggerLabel = getBranchTriggerLabel({
494468
activeWorktreePath,
495469
effectiveEnvMode,
496470
resolvedActiveBranch,
497471
});
498472

499-
function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) {
473+
function renderPickerItem(itemValue: string, index: number) {
500474
if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) {
501475
return (
502476
<ComboboxItem
503477
hideIndicator
504478
key={itemValue}
505479
index={index}
506480
value={itemValue}
507-
style={style}
508481
onClick={() => {
509482
if (!prReference || !onCheckoutPullRequestRequest) {
510483
return;
@@ -529,7 +502,6 @@ export function BranchToolbarBranchSelector({
529502
key={itemValue}
530503
index={index}
531504
value={itemValue}
532-
style={style}
533505
onClick={() => createBranch(trimmedBranchQuery)}
534506
>
535507
<span className="truncate">Create new branch &quot;{trimmedBranchQuery}&quot;</span>
@@ -557,7 +529,6 @@ export function BranchToolbarBranchSelector({
557529
key={itemValue}
558530
index={index}
559531
value={itemValue}
560-
style={style}
561532
onClick={() => selectBranch(branch)}
562533
>
563534
<div className="flex w-full items-center justify-between gap-2">
@@ -575,8 +546,13 @@ export function BranchToolbarBranchSelector({
575546
autoHighlight
576547
virtualized={shouldVirtualizeBranchList}
577548
onItemHighlighted={(_value, eventDetails) => {
578-
if (!isBranchMenuOpen || eventDetails.index < 0) return;
579-
branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" });
549+
if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") {
550+
return;
551+
}
552+
branchListRef.current?.scrollIndexIntoView?.({
553+
index: eventDetails.index,
554+
animated: false,
555+
});
580556
}}
581557
onOpenChange={handleOpenChange}
582558
open={isBranchMenuOpen}
@@ -604,30 +580,30 @@ export function BranchToolbarBranchSelector({
604580
</div>
605581
<ComboboxEmpty>No branches found.</ComboboxEmpty>
606582

607-
<ComboboxList ref={setBranchListRef} className="max-h-56">
608-
{shouldVirtualizeBranchList ? (
609-
<div
610-
className="relative"
611-
style={{
612-
height: `${branchListVirtualizer.getTotalSize()}px`,
583+
{shouldVirtualizeBranchList ? (
584+
<ComboboxListVirtualized>
585+
<LegendList<string>
586+
ref={branchListRef}
587+
data={filteredBranchPickerItems}
588+
keyExtractor={(item) => item}
589+
renderItem={({ item, index }) => renderPickerItem(item, index)}
590+
estimatedItemSize={28}
591+
drawDistance={336}
592+
onEndReached={() => {
593+
if (hasNextPage && !isFetchingNextPage) {
594+
void fetchNextPage().catch(() => undefined);
595+
}
613596
}}
614-
>
615-
{virtualBranchRows.map((virtualRow) => {
616-
const itemValue = filteredBranchPickerItems[virtualRow.index];
617-
if (!itemValue) return null;
618-
return renderPickerItem(itemValue, virtualRow.index, {
619-
position: "absolute",
620-
top: 0,
621-
left: 0,
622-
width: "100%",
623-
transform: `translateY(${virtualRow.start}px)`,
624-
});
625-
})}
626-
</div>
627-
) : (
628-
filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index))
629-
)}
630-
</ComboboxList>
597+
style={{ maxHeight: "14rem" }}
598+
/>
599+
</ComboboxListVirtualized>
600+
) : (
601+
<ComboboxList ref={setBranchListRef} className="max-h-56">
602+
{filteredBranchPickerItems.map((itemValue, index) =>
603+
renderPickerItem(itemValue, index),
604+
)}
605+
</ComboboxList>
606+
)}
631607
{branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null}
632608
</ComboboxPopup>
633609
</Combobox>

0 commit comments

Comments
 (0)