Skip to content

Migrate chat scrolling and branch lists to LegendList#1953

Merged
juliusmarminge merged 20 commits intomainfrom
t3code/legend-list-chat-scroll
Apr 13, 2026
Merged

Migrate chat scrolling and branch lists to LegendList#1953
juliusmarminge merged 20 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 chat/branch-list scrolling and virtualization behavior, which can introduce subtle UX regressions (auto-scroll, anchoring, pagination) despite targeted tests.

Overview
Switches the web app’s virtualization layer from @tanstack/react-virtual to @legendapp/list for both the chat timeline and the branch selector, removing the custom scroll/measurement plumbing and dropping @tanstack/react-virtual from dependencies.

ChatView/MessagesTimeline now rely on LegendList’s maintain scroll-at-end behavior, with debounced scroll-to-bottom pill visibility, self-ticking elapsed timers, and row-level state/store subscriptions to reduce full-list re-renders.

Branch selection lists are virtualized via LegendList inside a new ComboboxListVirtualized wrapper, with updated keyboard highlight scrolling and an added browser regression test to ensure the worktree branch picker opens anchored at the top.

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

Note

Migrate chat timeline and branch list scrolling to LegendList virtualization

  • Replaces @tanstack/react-virtual with @legendapp/list in both MessagesTimeline.tsx and BranchToolbarBranchSelector.tsx, removing all custom scroll/measurement logic.
  • ChatView now delegates auto-scroll and scroll-to-bottom pill visibility to LegendList's onIsAtEndChange callback with a 150ms debouncer for hiding the pill.
  • Work group expand/collapse state is now local to each row; changed files expand/collapse is persisted in a global UI store keyed by routeThreadKey and turnId, replacing parent-prop-driven state.
  • "Working for" and streaming message meta timers are now self-ticking per-row components, removing the periodic nowIso prop that previously caused full list re-renders.
  • Adds computeStableMessagesTimelineRows to preserve row object identity across renders, reducing unnecessary re-renders under LegendList memoization boundaries.
  • Risk: expandedWorkGroups, onToggleWorkGroup, changedFilesExpandedByTurnId, and onSetChangedFilesExpanded props are removed from MessagesTimeline; callers passing these props will break.

Macroscope summarized 7e4ad44.

- 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: 45911304-63bb-4dc3-a4d2-198382d9c725

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

Major refactor replacing @tanstack/react-virtual with @legendapp/list for chat scrolling and branch lists. While the changes simplify scroll handling by delegating to the library, they substantially alter runtime scroll behavior, remove ~1200 lines of virtualization tests, and replace core abstractions. The scope and removal of test coverage 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.

Autofix Details

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.

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 07fd60c

cursoragent and others added 3 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
- Move timeline row state into local/context-driven components to reduce list-wide rerenders
- Fix scroll-to-end behavior when sending messages so LegendList stays pinned correctly
- Add live ticking timers for working and streaming message metadata
@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Apr 12, 2026
- Drop the react-scan auto script from the web entry HTML
- Keep the app shell leaner for production
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.

Autofix Details

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

  • ✅ Fixed: Stable-rows fallback returns previous iteration order
    • Removed the stale fallback that returned Array.from(prev.values()) with old insertion order, and now always return the result array which has the correct current order and stable references.

Create PR

Or push these changes by commenting:

@cursor push 5aa1d999df
Preview (5aa1d999df)
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -230,10 +230,7 @@
   // from TimelineRowCtx, which propagates through LegendList's memo.
   const renderItem = useCallback(
     ({ item }: { item: MessagesTimelineRow }) => (
-      <div
-        className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"
-        data-timeline-root="true"
-      >
+      <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden" data-timeline-root="true">
         <TimelineRowContent row={item} />
       </div>
     ),
@@ -402,9 +399,7 @@
                 <div className="my-3 flex items-center gap-3">
                   <span className="h-px flex-1 bg-border" />
                   <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
-                    {ctx.completionSummary
-                      ? `Response • ${ctx.completionSummary}`
-                      : "Response"}
+                    {ctx.completionSummary ? `Response • ${ctx.completionSummary}` : "Response"}
                   </span>
                   <span className="h-px flex-1 bg-border" />
                 </div>
@@ -794,7 +789,6 @@
   return useMemo(() => {
     const prev = prevById.current;
     const next = new Map<string, MessagesTimelineRow>();
-    let anyChanged = false;
 
     const result = rows.map((row) => {
       const prevRow = prev.get(row.id);
@@ -803,13 +797,11 @@
         return prevRow;
       }
       next.set(row.id, row);
-      anyChanged = true;
       return row;
     });
 
     prevById.current = next;
-    // If nothing changed and length matches, reuse the previous array reference
-    return anyChanged || rows.length !== prev.size ? result : Array.from(prev.values());
+    return result;
   }, [rows]);
 }

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

- Move LegendList scroll-to-end to commit-time layout effect
- Debounce showing the scroll-to-bottom pill during thread switches
- Pin LegendList to the current end before appending optimistic turns
- Simplify timeline row stabilization to always return the rebuilt array
- Keep MessagesTimeline row and response label JSX compact
- No behavioral change
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.

Autofix Details

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

  • ✅ Fixed: Unused anyChanged variable defeats array stability optimization
    • Added a prevResult ref and used the anyChanged flag to return the previous array reference when no rows changed, preserving referential identity for LegendList's data prop.

Create PR

Or push these changes by commenting:

@cursor push cb46db8887
Preview (cb46db8887)
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -785,11 +785,12 @@
  *  hasn't changed since last call, the previous object reference is reused. */
 function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {
   const prevById = useRef(new Map<string, MessagesTimelineRow>());
+  const prevResult = useRef<MessagesTimelineRow[]>([]);
 
   return useMemo(() => {
     const prev = prevById.current;
     const next = new Map<string, MessagesTimelineRow>();
-    let anyChanged = false;
+    let anyChanged = rows.length !== prev.size;
 
     const result = rows.map((row) => {
       const prevRow = prev.get(row.id);
@@ -803,6 +804,9 @@
     });
 
     prevById.current = next;
+
+    if (!anyChanged) return prevResult.current;
+    prevResult.current = result;
     return result;
   }, [rows]);
 }

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

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push cb46db8

…eStableRows

The anyChanged variable was assigned but never read, causing a new array
reference to always be returned from useMemo even when all rows were
structurally identical. This partially defeated the purpose of the
structural sharing hook for LegendList's data prop.

Now we track the previous result and return it when no rows changed,
preserving referential identity and avoiding unnecessary list
reconciliation.

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.

Autofix Details

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

  • ✅ Fixed: Unused groupId prop in WorkGroupSection
    • Removed the unused groupId prop from the WorkGroupSection component interface, destructuring, and call site.

Create PR

Or push these changes by commenting:

@cursor push 45227f2132
Preview (45227f2132)
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -296,9 +296,7 @@
       data-message-id={row.kind === "message" ? row.message.id : undefined}
       data-message-role={row.kind === "message" ? row.message.role : undefined}
     >
-      {row.kind === "work" && (
-        <WorkGroupSection groupId={row.id} groupedEntries={row.groupedEntries} />
-      )}
+      {row.kind === "work" && <WorkGroupSection groupedEntries={row.groupedEntries} />}
 
       {row.kind === "message" &&
         row.message.role === "user" &&
@@ -528,10 +526,8 @@
 /** Owns its own expand/collapse state so toggling re-renders only this row.
  *  State resets on unmount which is fine — work groups start collapsed. */
 const WorkGroupSection = memo(function WorkGroupSection({
-  groupId,
   groupedEntries,
 }: {
-  groupId: string;
   groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
 }) {
   const [isExpanded, setIsExpanded] = useState(false);

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

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 45227f2

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.

Autofix Details

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

  • ✅ Fixed: Ref-backed Maps won't trigger row re-renders for changed files
    • Added a monotonic mapsRevision counter that increments when the turnDiffSummaryByAssistantMessageId or revertTurnCountByUserMessageId Map references change, and included it in the sharedState useMemo deps so the context identity updates and triggers re-renders through LegendList's memo boundaries.

Create PR

Or push these changes by commenting:

@cursor push 05e58a0ce9
Preview (05e58a0ce9)
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -83,6 +83,9 @@
   timestampFormat: TimestampFormat;
   /** Stable getter — returns the current Maps from a ref. */
   getMaps: () => TimelineRowMaps;
+  /** Monotonic counter — increments when the backing Maps change so
+   *  context consumers re-render and read fresh values via getMaps(). */
+  mapsRevision: number;
   routeThreadKey: string;
   markdownCwd: string | undefined;
   resolvedTheme: "light" | "dark";
@@ -175,8 +178,9 @@
     }
   }, [listRef, onIsAtEndChange]);
 
-  // Volatile Maps go into a ref so they never bust the context identity.
-  // Components read them via getMaps() during their own render pass.
+  // Maps go into a ref so getMaps() is a stable function reference.
+  // A revision counter is included in the context value so consumers
+  // re-render when the backing Maps change.
   const mapsRef = useRef<TimelineRowMaps>({
     turnDiffSummaryByAssistantMessageId,
     revertTurnCountByUserMessageId,
@@ -186,6 +190,18 @@
     revertTurnCountByUserMessageId,
   };
   const getMaps = useCallback(() => mapsRef.current, []);
+  const mapsRevisionRef = useRef(0);
+  const prevDiffMapRef = useRef(turnDiffSummaryByAssistantMessageId);
+  const prevRevertMapRef = useRef(revertTurnCountByUserMessageId);
+  if (
+    prevDiffMapRef.current !== turnDiffSummaryByAssistantMessageId ||
+    prevRevertMapRef.current !== revertTurnCountByUserMessageId
+  ) {
+    prevDiffMapRef.current = turnDiffSummaryByAssistantMessageId;
+    prevRevertMapRef.current = revertTurnCountByUserMessageId;
+    mapsRevisionRef.current += 1;
+  }
+  const mapsRevision = mapsRevisionRef.current;
 
   // Memoised context value — only changes on state transitions, NOT on
   // every streaming chunk. Callbacks from ChatView are useCallback-stable.
@@ -198,6 +214,7 @@
       completionSummary,
       timestampFormat,
       getMaps,
+      mapsRevision,
       routeThreadKey,
       markdownCwd,
       resolvedTheme,
@@ -215,6 +232,7 @@
       completionSummary,
       timestampFormat,
       getMaps,
+      mapsRevision,
       routeThreadKey,
       markdownCwd,
       resolvedTheme,

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

- Replace the custom row context hook with React `use`
- Keep timeline row and changed-files rendering on the same shared context
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: Scroll-to-bottom pill may flash or misbehave on thread switch
    • Added a useEffect cleanup that cancels the debouncer on component unmount, preventing stale setShowScrollToBottom(true) calls after the component is no longer mounted.

Create PR

Or push these changes by commenting:

@cursor push 5082fcb421
Preview (5082fcb421)
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
@@ -1969,7 +1969,13 @@
     planSidebarDismissedForTurnRef.current = null;
   }, [activeThread?.id]);
 
+  // Cancel any pending debouncer timeout on unmount to avoid stale state updates.
   useEffect(() => {
+    const debouncer = showScrollDebouncer.current;
+    return () => debouncer.cancel();
+  }, []);
+
+  useEffect(() => {
     setIsRevertingCheckpoint(false);
   }, [activeThread?.id]);

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

- Pass assistant diff summaries and user revert counts into row data
- Remove ref-backed map lookups from row rendering
- Add coverage for summary and revert-count projection
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: Structural sharing returns stale array on row reorder
    • Added an element-wise identity check against the previous result array so that row reordering (same ids/content, different positions) is detected and returns the correctly ordered array.

Create PR

Or push these changes by commenting:

@cursor push bfc761195e
Preview (bfc761195e)
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -779,6 +779,16 @@
 
     prevById.current = next;
 
+    if (!anyChanged) {
+      const prevRes = prevResult.current;
+      for (let i = 0; i < result.length; i++) {
+        if (result[i] !== prevRes[i]) {
+          anyChanged = true;
+          break;
+        }
+      }
+    }
+
     if (!anyChanged) return prevResult.current;
     prevResult.current = result;
     return result;

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

Reviewed by Cursor Bugbot for commit eb77432. Configure here.

- Ignore non-keyboard highlight events when the branch menu is open
- Add a regression test for the preselected worktree branch picker
- Extract row stabilization into shared logic
- Preserve row identity across unchanged renders
- Add tests for unchanged and reordered rows
@juliusmarminge juliusmarminge merged commit 96c9306 into main Apr 13, 2026
12 checks passed
@juliusmarminge juliusmarminge deleted the t3code/legend-list-chat-scroll branch April 13, 2026 06:23
juliusmarminge added a commit that referenced this pull request Apr 13, 2026
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ 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