Skip to content

Migrate chat scrolling and branch lists to LegendList#1953

Open
juliusmarminge wants to merge 8 commits intomainfrom
t3code/legend-list-chat-scroll
Open

Migrate chat scrolling and branch lists to LegendList#1953
juliusmarminge wants to merge 8 commits intomainfrom
t3code/legend-list-chat-scroll

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 12, 2026

Summary

  • Replaced chat timeline scrolling and branch selector virtualization with @legendapp/list.
  • Simplified ChatView scroll state by delegating stick-to-bottom behavior to LegendList.
  • Removed the old tanstack/react-virtual chat scroll helpers and related tests.
  • Updated combobox plumbing to use the new virtualized list wrapper.

Testing

  • Not run in this turn.
  • Expected checks for this change: bun fmt, bun lint, bun typecheck, and bun run test.

Note

Medium Risk
Medium risk because it replaces core scrolling/virtualization behavior in the chat timeline and branch selector, which can introduce UX regressions (scroll position, pagination, stick-to-bottom) despite limited surface area outside UI.

Overview
Migrates list virtualization from @tanstack/react-virtual to @legendapp/list for both the chat timeline (MessagesTimeline/ChatView) and the branch selector dropdown.

Chat scrolling is simplified by delegating stick-to-bottom, initial alignment, and scroll-to-end behavior to LegendList APIs (with a new onIsAtEndChange signal driving the “scroll to bottom” pill), removing custom scroll handlers/threshold logic and deleting the old chat-scroll utilities.

Branch selector virtualization now renders via LegendList inside a new ComboboxListVirtualized wrapper, using LegendList ref methods for index-into-view and onEndReached for pagination, and drops the prior virtualizer plumbing.

Removes now-obsolete row height estimation code and multiple virtualization/estimator-focused tests, and updates dependencies to add @legendapp/list and remove @tanstack/react-virtual.

Reviewed by Cursor Bugbot for commit 85616f1. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Replace @tanstack/react-virtual with LegendList for chat and branch list scrolling

  • Replaces the manual scroll container, ResizeObserver, and @tanstack/react-virtual virtualizer in MessagesTimeline.tsx and ChatView.tsx with LegendList, which handles end-aligned rendering, maintain-at-end scrolling, and item size estimation natively.
  • The branch selector in BranchToolbarBranchSelector.tsx now virtualizes its dropdown list via LegendList, using onEndReached for infinite pagination instead of a scroll event listener.
  • Height estimation helpers and virtualizer snapshot logic are removed from MessagesTimeline.logic.ts; the MessagesTimeline component now reports scroll end state via an onIsAtEndChange callback instead of emitting virtualizer snapshots.
  • Browser-based virtualization tests for MessagesTimeline and chat timeline height parity are removed as they no longer apply.
  • Risk: estimatedItemSize=90 is a fixed estimate; items significantly taller than this may cause layout jitter until measured.

Macroscope summarized 85616f1.

- Replace custom chat auto-scroll and branch virtualization with LegendList
- Remove deprecated chat scroll helpers and related tests
- Add LegendList dependency for list rendering
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 12, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d6f068de-4ff1-450b-8217-9525fd86d087

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/legend-list-chat-scroll

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Apr 12, 2026
- Reset the branch list correctly for virtualized and non-virtualized views
- Preserve formatting cleanup in web package and timeline logic
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 12, 2026

Approvability

Verdict: Needs human review

This PR replaces the entire chat scrolling and virtualization infrastructure, migrating from @tanstack/react-virtual to a beta version of @legendapp/list. The changes affect core runtime behavior including auto-scroll detection, scroll anchoring, and message height virtualization. While it simplifies the codebase by removing ~1500 lines of custom scroll handling, the scope and impact on the core chat experience warrant human review.

You can customize Macroscope's approvability policy. Learn more.

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared a fix for 1 of the 2 issues found in the latest run.

  • ✅ Fixed: Unused scrollToEnd dependency in callback, refs accessed directly
    • Replaced direct legendListRef.current?.scrollToEnd?.({ animated: true }) calls in both onSend and onSubmitPlanFollowUp with scrollToEnd(true) to use the wrapper consistently.

Create PR

Or push these changes by commenting:

@cursor push b3c9d5931b
Preview (b3c9d5931b)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -2435,7 +2435,7 @@
     // Sending a message should always bring the latest user turn into view.
     isAtEndRef.current = true;
     requestAnimationFrame(() => {
-      legendListRef.current?.scrollToEnd?.({ animated: true });
+      scrollToEnd(true);
       setShowScrollToBottom(false);
     });
 
@@ -2830,7 +2830,7 @@
       ]);
       isAtEndRef.current = true;
       requestAnimationFrame(() => {
-        legendListRef.current?.scrollToEnd?.({ animated: true });
+        scrollToEnd(true);
         setShowScrollToBottom(false);
       });

You can send follow-ups to the cloud agent here.

- Drop browser harness measurements for timeline height parity
- Delete the unused timelineHeight helper and its unit tests
- Stop forcing LegendList to scroll to the end on thread mount
- Rely on existing scroll state handling instead
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Uncancelled requestAnimationFrame in send message handlers
    • Added a pendingSendScrollFrameRef to track RAF IDs from both send handlers, cancel any pending frame before scheduling a new one, and cancel on component unmount via a cleanup effect.

Create PR

Or push these changes by commenting:

@cursor push 546899ceba
Preview (546899ceba)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -700,6 +700,7 @@
   const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
   const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
   const sendInFlightRef = useRef(false);
+  const pendingSendScrollFrameRef = useRef<number | null>(null);
   const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
 
   const terminalState = useTerminalStateStore((state) =>
@@ -1176,6 +1177,13 @@
       }
     };
   }, [clearAttachmentPreviewHandoffs]);
+  useEffect(() => {
+    return () => {
+      if (pendingSendScrollFrameRef.current != null) {
+        cancelAnimationFrame(pendingSendScrollFrameRef.current);
+      }
+    };
+  }, []);
   const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
     if (previewUrls.length === 0) return;
 
@@ -2423,7 +2431,11 @@
     ]);
     // Sending a message should always bring the latest user turn into view.
     isAtEndRef.current = true;
-    requestAnimationFrame(() => {
+    if (pendingSendScrollFrameRef.current != null) {
+      cancelAnimationFrame(pendingSendScrollFrameRef.current);
+    }
+    pendingSendScrollFrameRef.current = requestAnimationFrame(() => {
+      pendingSendScrollFrameRef.current = null;
       legendListRef.current?.scrollToEnd?.({ animated: true });
       setShowScrollToBottom(false);
     });
@@ -2818,7 +2830,11 @@
         },
       ]);
       isAtEndRef.current = true;
-      requestAnimationFrame(() => {
+      if (pendingSendScrollFrameRef.current != null) {
+        cancelAnimationFrame(pendingSendScrollFrameRef.current);
+      }
+      pendingSendScrollFrameRef.current = requestAnimationFrame(() => {
+        pendingSendScrollFrameRef.current = null;
         legendListRef.current?.scrollToEnd?.({ animated: true });
         setShowScrollToBottom(false);
       });

You can send follow-ups to the cloud agent here.

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Infinite scroll listener inactive for virtualized branch list
    • Guarded the branches.length useEffect to skip when shouldVirtualizeBranchList is true, since branchListScrollElementRef is null in that path and the LegendList onEndReached callback already handles infinite loading for the virtualized case.

Create PR

Or push these changes by commenting:

@cursor push 6f4824801a
Preview (6f4824801a)
diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx
--- a/apps/web/src/components/BranchToolbarBranchSelector.tsx
+++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx
@@ -460,8 +460,9 @@
   }, [isBranchMenuOpen, maybeFetchNextBranchPage]);
 
   useEffect(() => {
+    if (shouldVirtualizeBranchList) return;
     maybeFetchNextBranchPage();
-  }, [branches.length, maybeFetchNextBranchPage]);
+  }, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]);
 
   const triggerLabel = getBranchTriggerLabel({
     activeWorktreePath,

You can send follow-ups to the cloud agent here.

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 6f48248

When shouldVirtualizeBranchList is true, branchListScrollElementRef stays
null because setBranchListRef is only attached to the non-virtualized
ComboboxList. The branches.length effect would call maybeFetchNextBranchPage
which bails out immediately due to the null scroll element.

Guard the effect to skip when virtualized, since the LegendList onEndReached
callback already handles infinite loading in that path, and with 40+ items
the visible area is always filled.

Applied via @cursor push command
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Scroll-to-bottom button not reset on thread switch
    • Added isAtEndRef.current = true and setShowScrollToBottom(false) to the existing useEffect keyed on activeThread?.id so scroll state is properly reset when switching threads.

Create PR

Or push these changes by commenting:

@cursor push 07fd60cc17
Preview (07fd60cc17)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -1968,6 +1968,8 @@
   useEffect(() => {
     setExpandedWorkGroups({});
     setPullRequestDialogState(null);
+    isAtEndRef.current = true;
+    setShowScrollToBottom(false);
     if (planSidebarOpenOnNextThreadRef.current) {
       planSidebarOpenOnNextThreadRef.current = false;
       setPlanSidebarOpen(true);

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 1620842. Configure here.

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 07fd60c

cursoragent and others added 2 commits April 12, 2026 17:35
Reset isAtEndRef and showScrollToBottom when activeThread changes so stale
scroll state from a previous thread does not persist into the new one.

Applied via @cursor push command
- Scroll LegendList to the end without animation when sending messages
- Avoid mid-flight layout changes from landing the view at the wrong position
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants