Skip to content

Fix mobile legend anchor under automatic iOS insets#3684

Open
juliusmarminge wants to merge 7 commits into
mainfrom
t3code/fix-mobile-legend-anchor
Open

Fix mobile legend anchor under automatic iOS insets#3684
juliusmarminge wants to merge 7 commits into
mainfrom
t3code/fix-mobile-legend-anchor

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Jul 3, 2026

Copy link
Copy Markdown
Member

Summary

  • Adjust the thread feed anchor offset on iOS when automatic content insets are enabled.
  • Fold the header height into the anchor math so newly sent messages do not anchor beneath the header.
  • Keep maintainScrollAtEnd from snapping to the wrong position when UIKit-managed insets are in play.

Testing

  • Not run (PR summary only; no local verification requested).
  • Existing change is limited to apps/mobile/src/features/threads/ThreadFeed.tsx anchor calculations.

Note

High Risk
Touches core iOS scroll, inset, and keyboard integration via vendor patches and complex list remount/anchor behavior; regressions could affect every thread send and keyboard interaction, though scope is mobile UI only.

Overview
iOS thread scrolling is reworked so KeyboardAwareLegendList behaves correctly with transparent headers and contentInsetAdjustmentBehavior="automatic": anchor offset now includes navigation header height, bottom inset math compensates for UIKit double-counting the home indicator, and the list gains patched props (contentInsetStartAdjustment, contentInsetEndStaticAdjustment, adjustedInsetCompensation, scrollToOverflowEnabled) plus animated maintainScrollAtEnd and a layout effect to re-report composer inset after empty→filled remounts.

Thread detail / composer under-reports measured overlay height by the safe-area bottom (fourth arg to useKeyboardChatComposerInset), drops extra overlay paddingTop, and animates the working pill and pending approval/input chrome.

UX polish: Reanimated layout and fade transitions on the composer, feed messages (fresh rows only), and work-log rows.

Dependencies: Large pnpm patches for @legendapp/list@3.2.0 (scroll clamp, initial scroll, anchored end space, reveal hold) and react-native-keyboard-controller@1.21.13 (adjustedInsetCompensation, inset mirroring; scroll indicators follow keyboard only).

Release: eas.json production submit uses a new iOS ascAppId and sets Android track to internal.

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

Note

Fix mobile legend anchor position under automatic iOS safe-area insets

  • Adds adjustedInsetCompensation prop to KeyboardChatScrollView (via a patch to react-native-keyboard-controller) so keyboard/scroll math accounts for the safe-area bottom inset on iOS when automatic content insets are active.
  • In ThreadDetailScreen.tsx, subtracts the native inset overcount from the reported overlay height passed to useKeyboardChatComposerInset, preventing the legend anchor from being displaced by the double-counted inset.
  • In ThreadFeed.tsx, uses HeaderHeightContext to compute anchorTopInset and passes contentInsetStartAdjustment, contentInsetEndStaticAdjustment, and adjustedInsetCompensation to KeyboardAwareLegendList when automatic insets are in use.
  • Also adds enter/exit animations to composer elements, feed items, work-log rows, and the working-duration pill across the thread UI.

Macroscope summarized 1b94454.

@coderabbitai

coderabbitai Bot commented Jul 3, 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: e026784b-abde-4858-9643-9000464f2387

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/fix-mobile-legend-anchor

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:S 10-29 changed lines (additions + deletions). labels Jul 3, 2026
Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx Outdated
@macroscopeapp

macroscopeapp Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

7 blocking correctness issues found. This PR modifies mobile scroll positioning and content inset handling through extensive third-party library patches and new animation logic. Multiple unresolved review comments identify potential issues, including a high-severity concern about Android production releases being misdirected to internal testing.

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

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:S 10-29 changed lines (additions + deletions). labels Jul 4, 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 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Header blur uses wrong inset
    • handleScroll now measures the scroll threshold with anchorTopInset instead of the zeroed topContentInset, so header material visibility toggles at the true rest position under iOS automatic content insets while non-automatic behavior is unchanged.

Create PR

Or push these changes by commenting:

@cursor push 18fb3f23d9
Preview (18fb3f23d9)
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -1282,11 +1282,15 @@
     },
     [props.onHeaderMaterialVisibilityChange],
   );
+  // anchorTopInset, not topContentInset: with automatic insets the list rests
+  // at contentOffset.y = -headerHeight (the inset only exists in UIKit's
+  // adjustedContentInset), so the header inset must be added back for the
+  // material to toggle relative to the true rest position.
   const handleScroll = useCallback(
     (event: NativeSyntheticEvent<NativeScrollEvent>) => {
-      reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + topContentInset > 6);
+      reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
     },
-    [reportHeaderMaterialVisibility, topContentInset],
+    [reportHeaderMaterialVisibility, anchorTopInset],
   );
   const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
     const nextWidth = Math.round(event.nativeEvent.layout.width);

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

Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx
Comment thread patches/@legendapp__list@3.2.0.patch
@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 Jul 4, 2026
- Fold header inset into the anchor offset when iOS automatic content insets are enabled
- Prevent new messages from anchoring under the header and snapping past earlier messages

@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.

Autofix Details

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

  • ✅ Fixed: Work pill height estimate stale
    • Increased WORKING_INDICATOR_HEIGHT from 44 to 52 so the overlay height estimate includes the pill wrapper's new pt-2 (8px) padding, keeping the pre-measurement inset math consistent with the measured layout.

Create PR

Or push these changes by commenting:

@cursor push 8b598399e0
Preview (8b598399e0)
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
@@ -174,7 +174,10 @@
   }, [threadId, feed]);
 }
 
-const WORKING_INDICATOR_HEIGHT = 44;
+// Estimated height of WorkingDurationPill including its pt-2/pb-2 wrapper
+// padding; must track the pill layout so the pre-measurement overlay height
+// estimate matches what onComposerLayout later reports.
+const WORKING_INDICATOR_HEIGHT = 52;
 
 const WorkingDurationPill = memo(function WorkingDurationPill(props: {
   readonly startedAt: string;

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

Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx
- Patch LegendList and keyboard inset handling for transparent headers
- Animate composer, feed, and work-log transitions more smoothly
- Add tests and dependency patches for the mobile scroll fixes
Comment thread patches/@legendapp__list@3.2.0.patch
// composer-height short of the end. Layout effect: it must land before the
// list's first positioning tick or the one-shot initial scroll misses it.
const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`;
useLayoutEffect(() => {

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:1330

The new useLayoutEffect keyed on listMountKey reads props.contentInsetEndAdjustment.value and reports it to the remounted list, but that shared value still holds the previous thread's composer-overlay height when the thread changes (because useKeyboardChatComposerInset deduplicates by height and hasn't re-measured the fresh overlay yet). initialScrollAtEnd is one-shot during attach, so the first scroll-to-end on a thread switch computes with a stale bottom inset and rests too high or too low by the overlay-height delta. Consider resetting contentInsetEndAdjustment.value to 0 on thread change before the list remounts, or guarding the effect so it only reports the inset once the composer overlay has re-measured for the current thread.

Also found in 1 other location(s)

apps/mobile/src/features/threads/ThreadDetailScreen.tsx:195

Adding pt-2 to WorkingDurationPill makes the overlay 8 px taller, but the parent still budgets a fixed WORKING_INDICATOR_HEIGHT = 44 when activeWorkStartedAt is set. That leaves the thread feed's estimated bottom inset/anchor space too small whenever this pill is shown, so the last message or empty-state content can end up partially underneath the floating overlay until later layout correction.

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

The new `useLayoutEffect` keyed on `listMountKey` reads `props.contentInsetEndAdjustment.value` and reports it to the remounted list, but that shared value still holds the previous thread's composer-overlay height when the thread changes (because `useKeyboardChatComposerInset` deduplicates by height and hasn't re-measured the fresh overlay yet). `initialScrollAtEnd` is one-shot during attach, so the first scroll-to-end on a thread switch computes with a stale bottom inset and rests too high or too low by the overlay-height delta. Consider resetting `contentInsetEndAdjustment.value` to 0 on thread change before the list remounts, or guarding the effect so it only reports the inset once the composer overlay has re-measured for the current thread.

Also found in 1 other location(s):
- apps/mobile/src/features/threads/ThreadDetailScreen.tsx:195 -- Adding `pt-2` to `WorkingDurationPill` makes the overlay 8 px taller, but the parent still budgets a fixed `WORKING_INDICATOR_HEIGHT = 44` when `activeWorkStartedAt` is set. That leaves the thread feed's estimated bottom inset/anchor space too small whenever this pill is shown, so the last message or empty-state content can end up partially underneath the floating overlay until later layout correction.

- Pass safe-area inset compensation through the mobile thread list
- Adjust keyboard controller and legend list math so end anchors stay aligned
- Fix overlay spacing and drag handling to prevent end-position drift
@juliusmarminge juliusmarminge force-pushed the t3code/fix-mobile-legend-anchor branch from 8bcfdef to 60867bd Compare July 4, 2026 21:46

@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 60867bd. Configure here.

Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx

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 WORKING_INDICATOR_HEIGHT = 44;

WorkingDurationPill now adds pt-2 (8px top padding), but WORKING_INDICATOR_HEIGHT is still 44. ThreadDetailScreen uses this constant for activeWorkIndicatorHeight, which feeds into estimatedOverlayHeight, contentInsetEndAdjustment, and the feed's contentBottomInset. The reserved overlay height no longer matches the pill's actual rendered height, so the feed's bottom inset is ~8px too short and the last content can slide under the floating pill. Update WORKING_INDICATOR_HEIGHT to reflect the added top padding, or derive the reserved height from the pill's measured layout instead of a hardcoded constant.

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

`WorkingDurationPill` now adds `pt-2` (8px top padding), but `WORKING_INDICATOR_HEIGHT` is still `44`. `ThreadDetailScreen` uses this constant for `activeWorkIndicatorHeight`, which feeds into `estimatedOverlayHeight`, `contentInsetEndAdjustment`, and the feed's `contentBottomInset`. The reserved overlay height no longer matches the pill's actual rendered height, so the feed's bottom inset is ~8px too short and the last content can slide under the floating pill. Update `WORKING_INDICATOR_HEIGHT` to reflect the added top padding, or derive the reserved height from the pill's measured layout instead of a hardcoded constant.

onMomentumScrollEnd(event);
}
},
+ onScrollBeginDrag: (event) => {

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 patches/@legendapp__list@3.2.0.patch:481

The onScrollBeginDrag handler is forwarded through the fns object, but fns is created with an empty dependency array []. After mount, updates to the onScrollBeginDrag prop are never captured — drags always invoke the callback captured on first render, so any parent state that the drag callback depends on goes stale. Consider adding the prop (or a ref holding it) to the dependency array so the handler stays current.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @patches/@legendapp__list@3.2.0.patch around line 481:

The `onScrollBeginDrag` handler is forwarded through the `fns` object, but `fns` is created with an empty dependency array `[]`. After mount, updates to the `onScrollBeginDrag` prop are never captured — drags always invoke the callback captured on first render, so any parent state that the drag callback depends on goes stale. Consider adding the prop (or a ref holding it) to the dependency array so the handler stays current.

Comment on lines +74 to +75
+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
(_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);

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 patches/@legendapp__list@3.2.0.patch:74

onSizeChanged receives the original unadjusted size, but the spacer actually applied to blankSpace.value is size - adjustedInsetCompensation. Any caller tracking the real anchored spacer via onSizeChanged will be off by the compensation amount, causing overlays or end-of-list calculations to drift by the safe-area inset. Pass the same adjusted value to onSizeChanged that is written to blankSpace.value.

Suggested change
+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
(_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
const adjustedSize = blankSpace.value;
(_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, adjustedSize);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @patches/@legendapp__list@3.2.0.patch around lines 74-75:

`onSizeChanged` receives the original unadjusted `size`, but the spacer actually applied to `blankSpace.value` is `size - adjustedInsetCompensation`. Any caller tracking the real anchored spacer via `onSizeChanged` will be off by the compensation amount, causing overlays or end-of-list calculations to drift by the safe-area inset. Pass the same adjusted value to `onSizeChanged` that is written to `blankSpace.value`.

<ControlPillMenu
actions={optionsMenuActions}
onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
<Animated.View entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)}>

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/ThreadComposer.tsx:843

The exiting={FadeOut} wrappers on the expanded-only sections (toolbar row, queue count, attachment strip) keep those elements mounted and occupying space until the exit animation finishes (~120–180 ms). ThreadDetailScreen measures the overlay height via onComposerLayout and uses it for the feed's bottom inset and anchor-scroll math, so when the composer collapses after send the measured height still reflects the expanded layout. This causes the feed to compute its resting position against the wrong inset and anchor the newly sent message too high relative to the collapsed composer. Consider removing the exiting animations on these sections (or using a layout-only transition) so the height measurement updates immediately when isExpanded flips to false.

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

The `exiting={FadeOut}` wrappers on the expanded-only sections (toolbar row, queue count, attachment strip) keep those elements mounted and occupying space until the exit animation finishes (~120–180 ms). `ThreadDetailScreen` measures the overlay height via `onComposerLayout` and uses it for the feed's bottom inset and anchor-scroll math, so when the composer collapses after send the measured height still reflects the expanded layout. This causes the feed to compute its resting position against the wrong inset and anchor the newly sent message too high relative to the collapsed composer. Consider removing the `exiting` animations on these sections (or using a layout-only transition) so the height measurement updates immediately when `isExpanded` flips to false.

Gate LegendList's readyToRender on a new insetEndRevealHold flag owned by
the inset-end settle watchdog: content stays hidden while the initial
end-scroll chases late inset/size reports, upgrades adaptiveRender to
normal while still hidden (the light->normal re-render wave was causing
~100ms of visible drift), then reveals only after the offset holds stable
for 3 consecutive frames (40-frame cap, released early on user drag).
First visible frame is the final resting position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
+ return false;

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 patches/@legendapp__list@3.2.0.patch:277

When contentSize < state.scrollLength and maintainInsetStartAdjustment > 0, doMaintainScrollAtEnd sets state.scroll = -maintainInsetStartAdjustment and returns true without calling scrollTo on the native scroller. So when a list shrinks from overflowing to underflow while maintainScrollAtEnd is active, the internal scroll state says the feed is at -headerInset but the native scroll view stays at its old offset, leaving the viewport and end-anchor out of sync. Consider dispatching a scrollTo({ animated, y: -maintainInsetStartAdjustment }) (or falling through to the normal scrollTo path) so the native scroller matches state.scroll.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @patches/@legendapp__list@3.2.0.patch around line 277:

When `contentSize < state.scrollLength` and `maintainInsetStartAdjustment > 0`, `doMaintainScrollAtEnd` sets `state.scroll = -maintainInsetStartAdjustment` and returns `true` without calling `scrollTo` on the native scroller. So when a list shrinks from overflowing to underflow while `maintainScrollAtEnd` is active, the internal scroll state says the feed is at `-headerInset` but the native scroll view stays at its old offset, leaving the viewport and end-anchor out of sync. Consider dispatching a `scrollTo({ animated, y: -maintainInsetStartAdjustment })` (or falling through to the normal `scrollTo` path) so the native scroller matches `state.scroll`.

- Point production iOS submit config at the new ASC app ID
- Keeps EAS submission aligned with the current app record
- Update `apps/mobile/eas.json` so production Android builds target the internal track
- Leave iOS release config unchanged
Comment thread apps/mobile/eas.json
"ascAppId": "6787819824"
},
"android": {
"track": "internal"

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.

🟠 High mobile/eas.json:52

The submit.production.android.track is set to "internal", so eas submit --profile production -p android uploads the release to Google Play's internal testing track instead of the production track. Because production is the only Android submit profile, production Android releases never reach end users — they go only to internal testers. If internal testing is intended as a staging step before promotion, consider documenting that workflow; otherwise set track to "production" (or remove it so the default production track is used).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/eas.json around line 52:

The `submit.production.android.track` is set to `"internal"`, so `eas submit --profile production -p android` uploads the release to Google Play's internal testing track instead of the production track. Because `production` is the only Android submit profile, production Android releases never reach end users — they go only to internal testers. If internal testing is intended as a staging step before promotion, consider documenting that workflow; otherwise set `track` to `"production"` (or remove it so the default production track is used).

Address two PR review findings on the automatic-inset work:
- ThreadFeed: the header-material scroll check used topContentInset (0
  under automatic insets) instead of anchorTopInset, so the header blur
  toggled a full header height too late. Use anchorTopInset to match the
  true rest position.
- ThreadDetailScreen: WORKING_INDICATOR_HEIGHT (44) no longer matched the
  pill's rendered height after the pt-2 padding was added; bump to 52 so
  the pre-measurement overlay estimate seeds an accurate bottom inset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.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.

1 participant