Skip to content

[codex] Upgrade Legend List chat scrolling#3545

Merged
juliusmarminge merged 5 commits into
mainfrom
t3code/upgrade-legend-list-chat
Jun 24, 2026
Merged

[codex] Upgrade Legend List chat scrolling#3545
juliusmarminge merged 5 commits into
mainfrom
t3code/upgrade-legend-list-chat

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Jun 24, 2026

Copy link
Copy Markdown
Member

What

  • upgrade @legendapp/list from 3.0.0-beta.44 to stable 3.2.0 across web and mobile, including the keyboard-controller peer requirement
  • implement the documented floating-composer pattern: keep the list full-height, measure the overlay, and feed its height through contentInsetEndAdjustment
  • anchor newly sent user messages near the top so streamed responses grow into reserved space below
  • replace native custom inset/near-end timing code with KeyboardAwareLegendList, useKeyboardChatComposerInset, and useKeyboardScrollToEnd
  • keep empty native chats mounted so stable v3's committed-data scrollToEnd and empty-data initialScrollAtEnd fixes handle the first message
  • preserve measurement caches when queued native messages become confirmed messages by using stable message IDs
  • add item types to the remaining heterogeneous Legend Lists and explicitly handle the now-async imperative methods
  • share and test the web/mobile AI-chat anchor policy

Why

Legend List v3's floating-composer and AI-chat APIs replace several local scroll and composer-height workarounds. Using the library primitives keeps the scrollbar full-height, prevents the composer from covering the final message, follows streaming output only from the live edge, and avoids pulling users away from older messages when the mobile keyboard opens.

References:

Validation

  • vp check
  • vp run typecheck
  • vp run lint:mobile
  • MessagesTimeline: 11 tests passed
  • shared chat-list policy: 2 tests passed
  • full vp test discovery: 521 suites / 4,013 tests passed; 15 unrelated desktop suites could not start because the local Electron binary is not installed correctly

Note

Upgrade chat list scrolling to use Legend List keyboard-aware anchored scrolling

  • Replaces manual scroll-to-end and near-end tracking in ThreadFeed and MessagesTimeline with KeyboardAwareLegendList and anchored end scrolling via a new resolveChatListAnchoredEndSpace utility in packages/shared.
  • After sending a message, the UI anchors the list to the newly created MessageId and scrolls to end once the message appears, replacing the previous optimistic-message scroll timing.
  • The chat composer is now rendered as an absolutely positioned overlay on web; its height is measured and passed to MessagesTimeline as contentInsetEndAdjustment so content is never obscured.
  • Removes queued/outbox message entries from the feed entirely — buildThreadFeed no longer accepts queued messages and threadActivity types no longer include a 'queued-message' variant.
  • Bumps @legendapp/list from 3.0.0-beta.44 to 3.2.0 on both web and mobile.
  • Risk: removal of 'queued-message' feed entries changes visible send state; optimistic messages sent but not yet persisted will no longer appear in the feed until the server confirms them.

Macroscope summarized 11e34a5.


Note

Medium Risk
Large cross-platform scroll and composer-layout changes affect core chat UX; queued messages no longer appear in the mobile feed until confirmed.

Overview
Upgrades @legendapp/list to 3.2.0 on web and mobile and replaces bespoke chat scroll/inset logic with Legend List’s floating composer and AI-chat anchoring APIs.

Web measures the composer overlay height (ResizeObserver) and passes it to the timeline as contentInsetEndAdjustment; on send it sets a timeline anchor to the new user message id and uses shared resolveChatListAnchoredEndSpace. The composer becomes an absolute overlay with glass/backdrop styling, and composer resize no longer forces stick-to-bottom. Virtualized lists gain getItemType and void on async imperative scroll calls.

Mobile switches the thread feed to KeyboardAwareLegendList with useKeyboardChatComposerInset / useKeyboardScrollToEnd, parent-owned list ref, freeze, and anchor scroll after send when onSendMessage returns MessageId | null. Custom near-end/bottom-inset helpers and in-feed queued-message rows are removed; the empty state stays mounted over the list for first-message scroll behavior.

Adds @t3tools/shared/chatList (tests included) and bumps related mobile peers (react-native-keyboard-controller, react-native-nitro-modules).

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

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

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: 03b73ab7-c28b-496d-b15b-16b0130fedd9

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/upgrade-legend-list-chat

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

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jun 24, 2026
extraData={listAppearanceData}
renderItem={renderItem}
keyExtractor={(entry) => `${entry.type}:${entry.id}`}
keyExtractor={(entry) => entry.id}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium threads/ThreadFeed.tsx:1451

keyExtractor returns entry.id directly, so queued messages and their delivered copies share the same list key. When both entries exist in props.feed during the send handoff, KeyboardAwareLegendList receives duplicate keys, causing it to reuse the wrong row or drop one message. Consider including the entry type in the key (e.g., \${entry.type}:${entry.id}``) to ensure uniqueness.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/ThreadFeed.tsx around line 1451:

`keyExtractor` returns `entry.id` directly, so queued messages and their delivered copies share the same list key. When both entries exist in `props.feed` during the send handoff, `KeyboardAwareLegendList` receives duplicate keys, causing it to reuse the wrong row or drop one message. Consider including the entry type in the key (e.g., `\`\${entry.type}:\${entry.id}\``) to ensure uniqueness.

Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx Outdated
@juliusmarminge juliusmarminge marked this pull request as ready for review June 24, 2026 19:08

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

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 using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Duplicate keys during outbox promotion
    • Added deduplication in buildThreadFeed to filter out queued messages whose messageId already exists in confirmed loadedMessages, preventing two list items from sharing the same key during the promotion window.
  • ✅ Fixed: Composer inset never measured
    • Converted composerOverlayRef from a plain useRef to a state-based callback ref (useState + setter as ref), making the useLayoutEffect depend on the element itself so the ResizeObserver re-attaches whenever the overlay mounts after the initial empty-thread render.

Create PR

Or push these changes by commenting:

@cursor push 4dbf9bfa67
Preview (4dbf9bfa67)
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -1265,6 +1265,10 @@
   const oldestLoadedMessageCreatedAt =
     options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null;
   const workLogEntries = deriveWorkLogEntries(thread.activities);
+  const confirmedMessageIds =
+    queuedMessages.length > 0
+      ? new Set(loadedMessages.map((message) => message.id))
+      : (undefined as never);
   const entries = Arr.sortWith(
     [
       ...loadedMessages.map<RawThreadFeedEntry>((message) => ({
@@ -1273,13 +1277,15 @@
         createdAt: message.createdAt,
         message,
       })),
-      ...queuedMessages.map<RawThreadFeedEntry>((queuedMessage) => ({
-        type: "queued-message",
-        id: queuedMessage.messageId,
-        createdAt: queuedMessage.createdAt,
-        queuedMessage,
-        sending: queuedMessage.messageId === dispatchingQueuedMessageId,
-      })),
+      ...queuedMessages
+        .filter((qm) => !confirmedMessageIds.has(qm.messageId))
+        .map<RawThreadFeedEntry>((queuedMessage) => ({
+          type: "queued-message",
+          id: queuedMessage.messageId,
+          createdAt: queuedMessage.createdAt,
+          queuedMessage,
+          sending: queuedMessage.messageId === dispatchingQueuedMessageId,
+        })),
       ...workLogEntries
         .filter((entry) => {
           if (options?.loadedMessages === undefined) {

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
@@ -1148,7 +1148,7 @@
     LastInvokedScriptByProjectSchema,
   );
   const legendListRef = useRef<LegendListRef | null>(null);
-  const composerOverlayRef = useRef<HTMLDivElement | null>(null);
+  const [composerOverlayEl, setComposerOverlayEl] = useState<HTMLDivElement | null>(null);
   const [composerOverlayHeight, setComposerOverlayHeight] = useState(0);
   const isAtEndRef = useRef(true);
   const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
@@ -1157,11 +1157,10 @@
   const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({});
 
   useLayoutEffect(() => {
-    const composerOverlay = composerOverlayRef.current;
-    if (!composerOverlay) return;
+    if (!composerOverlayEl) return;
 
     const updateHeight = () => {
-      const nextHeight = Math.ceil(composerOverlay.getBoundingClientRect().height);
+      const nextHeight = Math.ceil(composerOverlayEl.getBoundingClientRect().height);
       setComposerOverlayHeight((currentHeight) =>
         currentHeight === nextHeight ? currentHeight : nextHeight,
       );
@@ -1171,9 +1170,9 @@
     if (typeof ResizeObserver === "undefined") return;
 
     const observer = new ResizeObserver(updateHeight);
-    observer.observe(composerOverlay);
+    observer.observe(composerOverlayEl);
     return () => observer.disconnect();
-  }, []);
+  }, [composerOverlayEl]);
 
   const terminalUiState = useTerminalUiStateStore((state) =>
     selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef),
@@ -4805,7 +4804,7 @@
 
             {/* Input bar */}
             <div
-              ref={composerOverlayRef}
+              ref={setComposerOverlayEl}
               data-chat-composer-overlay="true"
               className={cn(
                 "pointer-events-none absolute inset-x-0 bottom-0 z-20 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-1.5 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-2",

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

Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx
Comment thread apps/web/src/components/ChatView.tsx Outdated
@macroscopeapp

macroscopeapp Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

3 blocking correctness issues found. This PR substantially refactors chat scrolling behavior across mobile and web platforms with new anchoring logic. Multiple unresolved review comments identify medium-severity bugs (duplicate keys, scroll yanking, blocked touches) that warrant human review before merging.

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

- Remove queued-message entries from thread feeds
- Anchor scroll behavior to confirmed messages and active thread keys
- Clean up composer overlay measurement and thread send state
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jun 24, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

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 using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Scroll guard blocks retry
    • Reset lastScrolledAnchorMessageIdRef to null in the .catch() handler so that subsequent effect runs can retry the scroll when scrollMessageToEnd fails.
  • ✅ Fixed: Queue wait hides working pill
    • Restored queuedSendStartedAt from the first queued outbox message's createdAt and passed it to deriveActiveWorkStartedAt instead of null, restoring parity with the web client.

Create PR

Or push these changes by commenting:

@cursor push 670582d839
Preview (670582d839)
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -292,6 +292,7 @@
       }
       lastScrolledAnchorMessageIdRef.current = anchorMessageId;
       void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => {
+        lastScrolledAnchorMessageIdRef.current = null;
         freeze.set(false);
       });
     });

diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts
--- a/apps/mobile/src/state/use-thread-composer-state.ts
+++ b/apps/mobile/src/state/use-thread-composer-state.ts
@@ -115,6 +115,7 @@
     };
   }, [selectedThreadDetail, selectedThreadShell]);
 
+  const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null;
   const activeWorkStartedAt = useMemo(() => {
     const selectedThread = selectedThreadDetail ?? selectedThreadShell;
     if (!selectedThread) {
@@ -124,9 +125,14 @@
     return deriveActiveWorkStartedAt(
       selectedThread.latestTurn,
       selectedThreadSessionActivity,
-      null,
+      queuedSendStartedAt,
     );
-  }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]);
+  }, [
+    queuedSendStartedAt,
+    selectedThreadDetail,
+    selectedThreadSessionActivity,
+    selectedThreadShell,
+  ]);
 
   const activeThreadBusy =
     !!selectedThread &&

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

Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Comment thread apps/mobile/src/state/use-thread-composer-state.ts
- Clear stale anchor state when scroll-to-end fails
- Remove end alignment from the mobile thread feed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

const listAppearanceData = useMemo(

listAppearanceData omits unsettledTurnId, but renderFeedEntry uses it to compute showAssistantMeta for terminal assistant messages. When a turn completes and latestTurn changes without new feed items, LegendList caches visible rows; the assistant message stays stuck in its in-progress presentation (missing copy/timestamp controls) until the row recycles. Consider adding unsettledTurnId to extraData so the row re-renders when the turn state changes.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/ThreadFeed.tsx around line 1173:

`listAppearanceData` omits `unsettledTurnId`, but `renderFeedEntry` uses it to compute `showAssistantMeta` for terminal assistant messages. When a turn completes and `latestTurn` changes without new feed items, `LegendList` caches visible rows; the assistant message stays stuck in its in-progress presentation (missing copy/timestamp controls) until the row recycles. Consider adding `unsettledTurnId` to `extraData` so the row re-renders when the turn state changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";

handleSendMessage sets anchorMessageId to the returned queued messageId, and the effect at lines 278–305 keeps scrolling to that anchor until the message appears in selectedThreadFeed. Since props.onSendMessage only enqueues the message and returns immediately, if delivery is delayed (offline, slow connection), the user can scroll elsewhere, then get yanked back to the bottom minutes later when the queued message is finally confirmed. Consider clearing the anchor immediately when the send is only queued, or anchoring to a locally rendered optimistic message instead of persisting until remote confirmation.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/ThreadDetailScreen.tsx around line 1:

`handleSendMessage` sets `anchorMessageId` to the returned queued `messageId`, and the effect at lines 278–305 keeps scrolling to that anchor until the message appears in `selectedThreadFeed`. Since `props.onSendMessage` only enqueues the message and returns immediately, if delivery is delayed (offline, slow connection), the user can scroll elsewhere, then get yanked back to the bottom minutes later when the queued message is finally confirmed. Consider clearing the anchor immediately when the send is only queued, or anchoring to a locally rendered optimistic message instead of persisting until remote confirmation.

- add configurable chat list anchor offset for composer chrome
- restyle the web composer with shared glass/inset chrome
- update mobile thread feed anchoring to match measured content

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

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 using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issue. You can view the agent here.

Reviewed by Cursor Bugbot for commit 11e34a5. Configure here.

/>
</div>
)}
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Lower chrome blocks timeline touches

Medium Severity

Bottom safe-area padding was moved from the pointer-events-none composer overlay root onto chat-composer-lower-chrome, which still uses default pointer events and a semi-transparent background. That full-width band sits above the message list and absorbs taps and scroll drags that previously reached the timeline.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 11e34a5. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bugbot Autofix determined this is a false positive.

The lower-chrome div inherits pointer-events: none from its parent overlay element because CSS pointer-events is an inherited property, so it correctly passes through all touches to the timeline beneath.

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

@juliusmarminge juliusmarminge merged commit 22f021e into main Jun 24, 2026
17 checks passed
@juliusmarminge juliusmarminge deleted the t3code/upgrade-legend-list-chat branch June 24, 2026 22:39
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.

1 participant