Skip to content

fix(mobile): stabilize thread composer and interactions - #5986

Open
juliusmarminge wants to merge 35 commits into
mainfrom
fix-rn-floating-composer-position
Open

fix(mobile): stabilize thread composer and interactions#5986
juliusmarminge wants to merge 35 commits into
mainfrom
fix-rn-floating-composer-position

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Aug 10, 2026

Copy link
Copy Markdown
Member

What changed

  • Keep the floating composer anchored after keyboard dismissal on iOS and Android.
  • Stop live-feed updates from pulling users back to the end while they are reading history.
  • Add a floating scroll-to-end control that uses the same keyboard-aware LegendList path.
  • Bound long pending-input forms to the viewport, keep their actions reachable, and preserve multi-select answers.
  • Add focused tests for live-follow, pending-input layout, and answer shaping.

This consolidates #5118 and #5915 into #5986 as the single branch to test and merge. Their original commit authorship is preserved.

Closes #5434.

Why

Several mobile thread interactions shared the same keyboard, overlay, and end-follow boundaries. Testing them together catches the real integration behavior: the composer stays attached to the keyboard, user-driven scrolling remains respected, the return-to-end affordance reflects that same state, and pending input remains usable in the remaining viewport.

Validation

  • vp test run apps/mobile/src/features/threads/thread-feed-live-follow.test.ts apps/mobile/src/features/threads/pendingUserInputLayout.test.ts apps/mobile/src/lib/threadActivity.test.ts — 24 tests passed.
  • vp run --filter @t3tools/mobile typecheck passed.
  • Targeted vp lint --report-unused-disable-directives passed for all changed TypeScript files.
  • node scripts/mobile-native-static-check.ts passed SwiftLint, ktlint, and detekt.
  • vp fmt --check passed for all changed files.
  • React Doctor scoped to the diff scored 88/100; the four remaining findings are non-blocking heuristics for the intentionally bounded question scroller, existing screen size, thread-switch reset, and a tiny normalization pass.

UI evidence

Pending input: before, after, and scroll video.

Scroll-to-end: Android interaction video.

Manual test plan

  1. Open and dismiss the keyboard repeatedly; confirm the composer returns to the bottom.
  2. Scroll upward while an agent is streaming; confirm new activity does not interrupt reading.
  3. Tap the down-arrow during and after momentum; confirm the thread returns to the end and the button disappears.
  4. Exercise a long pending-input request with single- and multi-select questions while the keyboard is open and closed.
  5. Repeat the keyboard and scrolling cases on both iOS and Android.

Model: GPT-5. Harness: Codex.

Note

Stabilize mobile thread composer, questionnaire card, and settings navigation

  • Rewrites PendingUserInputCard as a collapsible bar/overlay card with animated expand/collapse, scroll support, multi-select answers, and a Stop control; the composer is hidden (not unmounted) while a user-input request is active
  • Refactors ThreadFeed end-follow logic using a new resolveThreadFeedLiveFollow state machine that respects user scroll sessions and native momentum, and surfaces a floating scroll-to-end button in ThreadDetailScreen
  • Adds a native iOS context menu to ThreadComposer for model/option/runtime selection with haptic feedback, keeping the keyboard visible during changes
  • Restructures settings navigation into a nested SettingsContentStack inside SettingsSheetStack, fixing header transition artifacts on iOS 26 and removing the ClerkSettingsSheetDetentProvider
  • Patches @react-native-menu/menu to support in-place menu updates, nested submenus, subtitles, and keepsMenuPresented; patches @clerk/expo to prefer native UINavigationController pop on back; patches react-native-screens to avoid rebuilding header bar button items when config is unchanged
  • Risk: onChangePendingUserInputAnswer now receives a full UserInputQuestion object instead of a questionId string, and onSelectUserInputOption callers must pass the full question; multi-select answers are now string[] instead of string

Macroscope summarized faa634c.


Note

Medium Risk
Large touch surface across navigation, keyboard, list insets, and vendored patches; behavior is heavily platform-specific though covered by new unit tests.

Overview
This PR tightens mobile thread UX (composer, feed follow, pending input) and settings / auth navigation, with supporting native patches and dependency bumps.

Thread detail: Live-follow now uses resolveThreadFeedLiveFollow so streaming no longer yanks readers off history unless they return to the true end; a floating scroll-to-end control appears when follow is off. KeyboardStickyView only tracks the keyboard while it is visible so the composer does not stick mid-screen after dismiss. Pending user input becomes a collapsible bar that expands over the feed with animated bottom inset; the composer stays mounted but hidden, multi-select answers are supported, and option selection passes the full UserInputQuestion. On iOS, thread settings can change via a native ControlPillMenu without losing keyboard focus.

Settings: Settings routes nest under SettingsContent inside a headerless outer sheet stack; ClerkSettingsSheetDetent is removed. Clerk auth/profile back can pop natively (Clerk Expo patch). OTA update checks skip Metro/dev via isAppUpdateCheckAvailable.

Infrastructure: @legendapp/list 3.3.5 with keyboard/inset patches, @react-native-menu/menu patches for deeper menus and keepsMenuPresented, plus focused unit tests for live-follow, pending-input layout, and answer shaping.

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

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Aug 10, 2026
@coderabbitai

coderabbitai Bot commented Aug 10, 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 Plus

Run ID: 35cafe2b-6db5-4c80-b383-3e1ee88210cf

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

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

Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 11.3 KiB 11.3 KiB 0 B (0.0%) 15.1 KiB
Codex Thread snapshot wire 5.5 KiB 5.5 KiB −3 B (−0.1%) 7.3 KiB
Codex Live turn WebSocket wire 5.9 KiB 5.9 KiB +3 B (+0.1%) 7.8 KiB
Codex Live turn WebSocket decoded 49.7 KiB 49.7 KiB 0 B (0.0%) 66.4 KiB
Codex Live turn messages 16 16 0 (0.0%) 21
Claude Total thread wire 11.3 KiB 11.3 KiB +3 B (+0.0%) 15.1 KiB
Claude Thread snapshot wire 5.5 KiB 5.5 KiB −1 B (−0.0%) 7.3 KiB
Claude Live turn WebSocket wire 5.9 KiB 5.9 KiB +4 B (+0.1%) 7.8 KiB
Claude Live turn WebSocket decoded 50.6 KiB 50.6 KiB 0 B (0.0%) 66.4 KiB
Claude Live turn messages 16 16 0 (0.0%) 21

Baseline: b91a000 · PR result: faa634c · Source CI: failure

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 94.6 KiB
  • Claude decoded thread snapshot: 95.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@macroscopeapp

macroscopeapp Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. This PR introduces significant new mobile UI capabilities (collapsible questionnaire, native settings menu, scroll-to-end button) and refactors core scroll/navigation behavior rather than being a simple stabilization fix. Multiple high-severity findings remain open regarding viewport layout edge cases, native bar button caching issues, and scroll state synchronization.

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

@juliusmarminge
juliusmarminge force-pushed the fix-rn-floating-composer-position branch from 2059ed7 to 53cdcb7 Compare August 10, 2026 10:38
@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Aug 10, 2026
Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx Outdated
Comment thread apps/mobile/src/features/threads/thread-feed-live-follow.ts
Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx Outdated
juliusmarminge and others added 11 commits August 10, 2026 18:02
- Disable stale keyboard translation when the IME is hidden
- Pause live-follow immediately on user scroll and re-arm only at the actual end
- Add focused live-follow transition tests

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge
juliusmarminge force-pushed the fix-rn-floating-composer-position branch from 89af13d to 8f308a2 Compare August 10, 2026 16:02
@juliusmarminge juliusmarminge changed the title Fix mobile composer positioning and live-feed follow behavior fix(mobile): stabilize thread composer and interactions Aug 10, 2026
const selectedThreadFeed = props.selectedThreadFeed;
const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
const composerOverlapHeight = composerChrome + composerBottomInset;
const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({

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 threads/ThreadDetailScreen.tsx:229

derivePendingUserInputMaxHeight clamps the computed available height to a 160px minimum, so on a short or landscape phone with the keyboard open, the card can be 160px tall even when the true available space is far smaller (or negative). Because the card is inside a bottom-anchored overlay that is not itself scrollable, the top of the form is pushed off-screen and its initial questions are unreachable. Additionally, when both activePendingApproval and activePendingUserInput are non-null, the max-height calculation does not subtract the simultaneously rendered PendingApprovalCard, so the overlay holds both cards plus its gap and overflows the viewport, pushing controls or the composer off-screen. Consider allowing the card to shrink below 160px when space is constrained, and subtract the approval card's height from the available space when both are present.

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

`derivePendingUserInputMaxHeight` clamps the computed available height to a 160px minimum, so on a short or landscape phone with the keyboard open, the card can be 160px tall even when the true available space is far smaller (or negative). Because the card is inside a bottom-anchored overlay that is not itself scrollable, the top of the form is pushed off-screen and its initial questions are unreachable. Additionally, when both `activePendingApproval` and `activePendingUserInput` are non-null, the max-height calculation does not subtract the simultaneously rendered `PendingApprovalCard`, so the overlay holds both cards plus its gap and overflows the viewport, pushing controls or the composer off-screen. Consider allowing the card to shrink below 160px when space is constrained, and subtract the approval card's height from the available space when both are present.

Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx
Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Rebuilding UIBarButtonItems on every header option change (title, subtitle,
status) replaces the iOS 26 glass UIButton custom views while UIKit may be
animating one — stranding a menu capsule mid-morph or unmasking the back
button's glass capsule into a square. Reuse the applied items when the JS
bar-button configs are structurally unchanged, and only rebuild the bottom
toolbar when its configs, visibility, or owning screen actually changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the 📱 Native Change Changes the native fingerprint; merging blocks production OTAs until a new store build ships. label Aug 10, 2026
The menu library never assigned UIMenu.subtitle, so submenu rows (Model,
Reasoning, Runtime) lost their current-value summaries — set it alongside
a stable UIMenu identifier. Leaf picks now use keepsMenuPresented so
several dimensions can be adjusted in one visit, with the native view
pushing rebuilt actions into the visible menu via updateVisibleMenu so
checkmarks and subtitles refresh in place; identifiers keep the user's
submenu level across that refresh. All Settings… still closes the menu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread patches/@react-native-menu__menu@2.0.0.patch
juliusmarminge and others added 5 commits August 10, 2026 21:17
The Fabric codegen spec only declared destructive/disabled/hidden in the
action attributes struct, so keepsMenuPresented was silently dropped at
the JS→C++ boundary and menus always closed on selection. Declare it at
every level, add a third nesting level (submenus inside submenus were
being stripped the same way), forward both through convertActionsToObjC,
and guard the native view against reassigning UIButton.menu while the
menu is presented — that reassignment dismisses the presentation, which
would have defeated the attribute the moment refreshed actions arrived.
Deferred props land via setup() on dismissal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Overriding contextMenuInteraction(_:willDisplayMenuFor:animator:) on the
menu button — even just to flag presentation — shadows UIButton's own
implementation and degrades the button-anchored presentation into generic
context-menu chrome: an empty header row with a dismiss chevron rendered
above the actions. Track presentation from the two delegate methods the
class already overrode instead (configurationForMenuAtLocation /
willEndFor), flushing any deferred menu there so a stuck flag can never
serve a stale menu. Inline sections also skip the new stable identifier;
they don't navigate, so they stay closest to stock rendering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
updateVisibleMenu hands the block whichever menu level is currently on
screen — the navigated submenu after a keepsMenuPresented pick, not the
root. Returning the rebuilt root menu made UIKit render it as navigation
into a foreign menu, leaving a blank (or stale-titled) expanded-submenu
header row above the actions. Match the visible level against the rebuilt
tree by the stable UIMenu identifiers and return that node so the level
updates in place (checkmarks and header subtitle included); the root has
an auto identifier and falls through to a children-only replacement.
Verified on the iOS 26.5 simulator: fresh open, in-submenu pick with the
menu staying presented, and collapse back to root all render clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iOS keeps the *navigated submenu* presented when a keepsMenuPresented
action fires inside one, rendering an expanded-submenu header with no way
to pop back to the root — chrome that earns nothing. Nested picks (model,
select options, runtime) now close the menu like ChatGPT's picker; only
top-level boolean toggles keep it presented, where the root refreshes in
place cleanly. Verified on the iOS 26.5 simulator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ison

Re-enables keep-presented on nested picks behind
NESTED_PICKS_KEEP_MENU_PRESENTED (currently true) so both variants can be
compared on device: submenu stays open with in-place checkmark refresh vs
close-on-pick. Flip the constant to switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/mobile/src/features/threads/thread-settings-menu.ts Outdated
juliusmarminge and others added 4 commits August 10, 2026 22:50
A thread is bound to one harness, so the native menu now covers the
sheet's entire surface for existing threads (models incl. legacy, select
and boolean options, runtime). The sheet stays as the Android trigger
surface and the new-task draft picker; iOS threads no longer need the
hand-off row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes the UX comparison flag and hard-codes the ChatGPT-style behavior:
picks inside nested submenus (model, options, runtime) close the menu,
top-level boolean toggles keep it presented with in-place refresh. The
stay-open variant fought UIKit twice — the expanded-submenu header and
the bottom-anchored collapse dropping by the levels' height difference —
neither of which is fixable app-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
36pt circle instead of 44pt, floating 56pt above the composer overlay
instead of 112pt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Liquid-glass circle on supported devices (36pt, interactive shimmer),
44pt above the composer overlay; the bordered card circle stays as the
fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

When switching to a thread in a different environment that happens to share the same ThreadId, ThreadFeed is not remounted (it is keyed by threadId alone), so its internal endFollowEnabled latch keeps its previous value. Meanwhile this effect unconditionally sets the parent endFollowEnabled back to true, hiding the scroll-to-end button. The result is that the feed can remain in an unfollowed state with no visible control to return to the end. Consider keying ThreadFeed by scopedThreadKey (or resetting the latch via the existing onEndFollowEnabledChange callback) so the child latch and parent state stay in sync across environment switches.

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

When switching to a thread in a different environment that happens to share the same `ThreadId`, `ThreadFeed` is not remounted (it is keyed by `threadId` alone), so its internal `endFollowEnabled` latch keeps its previous value. Meanwhile this effect unconditionally sets the parent `endFollowEnabled` back to `true`, hiding the scroll-to-end button. The result is that the feed can remain in an unfollowed state with no visible control to return to the end. Consider keying `ThreadFeed` by `scopedThreadKey` (or resetting the latch via the existing `onEndFollowEnabledChange` callback) so the child latch and parent state stay in sync across environment switches.

juliusmarminge and others added 2 commits August 10, 2026 23:39
Interactive liquid glass can render larger than the requested 36pt box
(minimum touch size), which left the pill anchored top-left; center it
instead of assuming it fills the glass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pending user-input card now owns the input surface while expanded:
the composer hides (display none, so drafts and editor state survive) and
the card pads the home indicator itself. A header tap or the chevron
collapses it to a compact 'User input needed · N questions' pill so the
transcript is readable and the composer returns for free-text steering or
stopping the turn; collapse releases the keyboard with the hidden custom
answer inputs. Collapse state is keyed by request id so a new request
re-expands automatically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx Outdated
Collapsing the pending-input card no longer swaps the composer back in
mid-animation — that flip is what made the collapse fight the keyboard
dismissal. While a request is pending the composer stays hidden; the
collapsed state is a composer-style bar in the same slot (question count,
expand chevron, and its own stop control) and both states share one
animated root so a layout transition morphs bar ↔ card and glides the
keyboard-driven max-height changes instead of snapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
className="h-9 w-9"
onPress={props.onStopThread}
/>
) : null}

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.

Ungated pending-input Stop control

Low Severity

The collapsed pending-input bar shows Stop whenever onStopThread is passed, and ThreadDetailScreen always passes it. ThreadComposer only shows Stop when session status is running or starting, and handleStopThread no-ops otherwise, so this bar can present a control that does nothing.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dd0cd4f. Configure here.

juliusmarminge and others added 2 commits August 10, 2026 23:59
…phing

Frame-morphing one view between the composer-style bar and the full card
stranded the card mid-flight detached from the bottom slot (top edge
snapping to final position while height grew). Keyed remounts crossfade
the swap — the card rises in from the slot, the outgoing view fades in
place — and the layout transition remains only on the stable expanded
card, gliding keyboard-driven max-height changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The card's max height was derived from the binary keyboard visibility,
which only flips on keyboardDidHide — so after riding the keyboard down
via the sticky view, the card corrected its height in a second, separate
animation. Drive max height from the keyboard controller's animated
height in a worklet instead: the card now resizes frame-by-frame in sync
with the keyboard in both directions, and the layout transition (which
would chase the continuous animated style) is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx Outdated
juliusmarminge and others added 2 commits August 11, 2026 00:22
…s with it

The keyboard-open transitions desynced because the sticky translation is
a transform (applied same-frame on the UI thread) while the card's
keyboard-tracking max height is a layout prop (a Yoga pass behind) — the
card flashed over the nav header at its stale height, then left a gap
above the still-rising keyboard. The card now reserves keyboard space
permanently (last observed height, estimated before the first open), so
keyboard open/close is pure translation with no layout animation at all;
height changes only on rare discrete corrections, smoothed by the layout
transition. The collapse ghost fade is shortened to 90ms since it hangs
over the transcript while the feed inset snaps beneath it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pand

The collapse/expand swap changed the overlay's measured height by the
card-vs-bar delta in one frame, snapping the thread feed's bottom inset —
the transcript teleported with content clipped under the bar. The bar is
now the permanent in-flow footprint and the expanded card an absolutely
positioned overlay rising above it: the measured height never changes on
toggle, so the feed does not move at all, and request arrival/resolution
also stops jumping since the footprint matches the composer's size. The
card sinks back into the bar on collapse (FadeOutDown) instead of
ghosting translucently over the transcript.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/mobile/src/features/threads/PendingUserInputCard.tsx
…stionnaire

The overlay architecture left the feed's end inset at the bar footprint,
hiding the last ~300pt of transcript behind the expanded card. The card
now reports how far it extends above the bar, and that coverage is added
to the feed's end inset through a shared value animated with the same
220ms timing as the card's rise/sink — the chat end glides above the
card on expand and back down on collapse, keyboard-style, with no layout
snap anywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx Outdated
Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx Outdated
juliusmarminge and others added 2 commits August 11, 2026 01:26
Latest published version (the npm beta dist-tag is stale, predating
3.3.3). Brings upstream fixes for maintainScrollAtEnd staying pinned
during row measurement, scroll corrections preserving anchoredEndSpace
padding, end-anchoring accuracy after measurement changes, and
programmatic scroll settling — all directly relevant to the thread
feed's end-anchoring. Our keyboard/scroll-inset patch is ported onto
3.3.5; no hunks were absorbed upstream. Mobile typecheck and the
live-follow and pending-input layout tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ning

Simulator-verified iteration on the questionnaire choreography:

- One shared 170ms ease-out clock for the card's enter/exit and the feed
  inset glide, replacing the sluggish mixed 220ms timings.
- The inset extra animates only upward (expanding); collapsing steps it
  down instantly, invisible behind the sinking card.
- The list's own corrections for these inset changes drift on short
  content and compound across toggles (verified: transcript tail crept
  under the bar, worse after rapid cycles), so the end is re-pinned
  deterministically after each toggle settles whenever live-follow is
  engaged — a no-op when the anchor is already right.

Verified on the iOS 26.5 simulator with a software keyboard against
seeded short and long threads: collapse, expand, five rapid alternating
cycles, collapse with the keyboard up, and custom-answer draft
persistence all land with the transcript tail fully above the bar; the
80-message thread anchors at end, holds free-scroll position without
drift, and re-engages follow at the end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +356 to +360
() => {
void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => {
freeze.set(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 threads/ThreadDetailScreen.tsx:356

The inset-correction setTimeout in ThreadDetailScreen checks endFollowEnabledRef.current only when scheduling the timer. If follow is enabled when the questionnaire expands/collapses and the user scrolls up during the 60–220 ms window, ThreadFeed disables follow but the already-scheduled callback still calls scrollMessageToEnd, yanking the user back to the end. Re-check endFollowEnabledRef.current inside the timeout (or cancel the timer when follow is disabled).

Suggested change
() => {
void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => {
freeze.set(false);
});
},
() => {
if (!endFollowEnabledRef.current) {
return;
}
void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => {
freeze.set(false);
});
},
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/ThreadDetailScreen.tsx around lines 356-360:

The inset-correction `setTimeout` in `ThreadDetailScreen` checks `endFollowEnabledRef.current` only when scheduling the timer. If follow is enabled when the questionnaire expands/collapses and the user scrolls up during the 60–220 ms window, `ThreadFeed` disables follow but the already-scheduled callback still calls `scrollMessageToEnd`, yanking the user back to the end. Re-check `endFollowEnabledRef.current` inside the timeout (or cancel the timer when follow is disabled).

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

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

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit faa634c. Configure here.

});
},
expanding ? USER_INPUT_TOGGLE_DURATION_MS + 50 : 60,
);

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.

Settle scroll ignores follow opt-out

Medium Severity

The pending-input inset settle path gates on endFollowEnabledRef only when scheduling scrollMessageToEnd, not when the timeout fires. endFollowEnabled is also omitted from the effect deps, so a drag that disables follow during the expand/collapse window never cancels the timer. That can yank the feed back to the end after the user has already opted out of live-follow.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit faa634c. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📱 Native Change Changes the native fingerprint; merging blocks production OTAs until a new store build ships. 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.

[Bug]: Add a “Scroll to End” button on iPhone and iPad

3 participants