fix(mobile): stabilize thread composer and interactions - #5986
fix(mobile): stabilize thread composer and interactions#5986juliusmarminge wants to merge 35 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 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.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
ApprovabilityVerdict: 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. |
2059ed7 to
53cdcb7
Compare
- 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>
89af13d to
8f308a2
Compare
| const selectedThreadFeed = props.selectedThreadFeed; | ||
| const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; | ||
| const composerOverlapHeight = composerChrome + composerBottomInset; | ||
| const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ |
There was a problem hiding this comment.
🟠 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.
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>
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>
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>
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>
There was a problem hiding this comment.
🟠 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.
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>
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} |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit dd0cd4f. Configure here.
…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>
…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>
…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>
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>
| () => { | ||
| void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => { | ||
| freeze.set(false); | ||
| }); | ||
| }, |
There was a problem hiding this comment.
🟡 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).
| () => { | |
| 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).
There was a problem hiding this comment.
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).
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, | ||
| ); |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit faa634c. Configure here.


What changed
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 typecheckpassed.vp lint --report-unused-disable-directivespassed for all changed TypeScript files.node scripts/mobile-native-static-check.tspassed SwiftLint, ktlint, and detekt.vp fmt --checkpassed for all changed files.UI evidence
Pending input: before, after, and scroll video.
Scroll-to-end: Android interaction video.
Manual test plan
Model: GPT-5. Harness: Codex.
Note
Stabilize mobile thread composer, questionnaire card, and settings navigation
PendingUserInputCardas 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 activeThreadFeedend-follow logic using a newresolveThreadFeedLiveFollowstate machine that respects user scroll sessions and native momentum, and surfaces a floating scroll-to-end button inThreadDetailScreenThreadComposerfor model/option/runtime selection with haptic feedback, keeping the keyboard visible during changesSettingsContentStackinsideSettingsSheetStack, fixing header transition artifacts on iOS 26 and removing theClerkSettingsSheetDetentProvider@react-native-menu/menuto support in-place menu updates, nested submenus, subtitles, andkeepsMenuPresented; patches@clerk/expoto prefer native UINavigationController pop on back; patchesreact-native-screensto avoid rebuilding header bar button items when config is unchangedonChangePendingUserInputAnswernow receives a fullUserInputQuestionobject instead of aquestionIdstring, andonSelectUserInputOptioncallers must pass the full question; multi-select answers are nowstring[]instead ofstringMacroscope 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
resolveThreadFeedLiveFollowso 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.KeyboardStickyViewonly 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 fullUserInputQuestion. On iOS, thread settings can change via a nativeControlPillMenuwithout losing keyboard focus.Settings: Settings routes nest under
SettingsContentinside a headerless outer sheet stack;ClerkSettingsSheetDetentis removed. Clerk auth/profile back can pop natively (Clerk Expo patch). OTA update checks skip Metro/dev viaisAppUpdateCheckAvailable.Infrastructure:
@legendapp/list3.3.5 with keyboard/inset patches,@react-native-menu/menupatches for deeper menus andkeepsMenuPresented, 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.