From 0ca22a8143489f9ea48995c00073b32efe12bb2f Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:59:24 +0000 Subject: [PATCH 1/3] fix(mobile): the question card uses the whole screen while the keyboard is closed The card reserved a keyboard's height even when no keyboard was open, so on short screens like the folded iPhone Duo the answers got a sliver to scroll in. It now follows the live keyboard frame on the UI thread, in step with the sticky composer, so it never crosses the header as the keyboard moves. Closes #24290 Co-Authored-By: Claude Opus 5.5 --- .../features/threads/PendingUserInputCard.tsx | 19 +++++---- .../features/threads/ThreadDetailScreen.tsx | 41 ++++++++++--------- .../threads/pendingUserInputLayout.ts | 1 + 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 6be28f295d63..37992ceec94b 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -12,6 +12,7 @@ import Animated, { useAnimatedStyle, useSharedValue, withTiming, + type DerivedValue, type SharedValue, } from "react-native-reanimated"; @@ -33,7 +34,7 @@ import { export interface PendingUserInputCardProps { readonly canOperateThread: boolean; readonly pendingUserInput: PendingUserInput; - readonly maxHeight: number; + readonly maxHeight: DerivedValue; readonly collapsed: boolean; readonly onToggleCollapsed: () => void; readonly onStopThread?: () => void; @@ -121,12 +122,16 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { [cardHeight, notifyCoverage], ); const cardProgress = props.cardProgress; + const maxHeight = props.maxHeight; const cardAnimatedStyle = useAnimatedStyle(() => { const progress = cardProgress === undefined ? 1 : cardProgress.value; return { + maxHeight: maxHeight.value, transform: [{ translateY: (1 - progress) * cardHeight.value }], }; }); + const cardMaxHeightStyle = useAnimatedStyle(() => ({ maxHeight: maxHeight.value })); + const overlayHeightStyle = useAnimatedStyle(() => ({ height: maxHeight.value })); // On iOS the card stays MOUNTED while collapsed (hidden via the animated // style): expanding animates existing views on the UI thread the same @@ -194,11 +199,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { } layout={CARD_LAYOUT_TRANSITION} className="overflow-hidden gap-2.5 rounded-[20px] border border-border bg-card-alt p-4" - style={ - EXPANDED_CARD_IS_OVERLAY - ? [{ maxHeight: props.maxHeight }, cardAnimatedStyle] - : { maxHeight: props.maxHeight } - } + style={EXPANDED_CARD_IS_OVERLAY ? cardAnimatedStyle : cardMaxHeightStyle} > {bar} {EXPANDED_CARD_IS_OVERLAY ? ( - {card} - + ) : ( card )} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ad29dfe5c6ec..4afc699bd3f7 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -90,6 +90,7 @@ import { KeyboardController, KeyboardStickyView, useKeyboardState, + useReanimatedKeyboardAnimation, } from "react-native-keyboard-controller"; import Animated, { Easing, @@ -878,10 +879,8 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai ); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; - // The card's height RESERVES keyboard space at all times instead of tracking the keyboard: - // transforms (the sticky translation) apply same-frame on the UI thread while layout props lag - // a Yoga pass behind, so any height that follows the keyboard flashes the card over the nav - // header on the way up. + // A height React sets from keyboard state lags the sticky translation by a Yoga pass and + // flashes over the nav header on the way up, so the usage panel reserves the keyboard instead. const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0); useEffect(() => { if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) { @@ -890,12 +889,23 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai }, [lastKnownKeyboardHeight, liveKeyboardHeight]); const reservedKeyboardHeight = lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT; - const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ - windowHeight, - keyboardHeight: reservedKeyboardHeight, - navigationHeaderHeight, - composerOverlapHeight: composerBottomInset, - }); + // Waiting for a visibility render misses the first keyboard frames. + const keyboardStickyEnabled = + Platform.OS === "ios" + ? deviceType !== "duo-unfolded" || (composerOwnsKeyboard && !sidebarSearchActive) + : !keyboardStateSuspect; + // The question card reads the keyboard frame on the UI thread, in the same frame the sticky + // view translates, so it gets the full height while the keyboard is closed and never crosses + // the header while the keyboard moves. + const { height: keyboardTranslation } = useReanimatedKeyboardAnimation(); + const pendingUserInputMaxHeight = useDerivedValue(() => + derivePendingUserInputMaxHeight({ + windowHeight, + keyboardHeight: keyboardStickyEnabled ? -keyboardTranslation.value : 0, + navigationHeaderHeight, + composerOverlapHeight: composerBottomInset, + }), + ); const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes @@ -946,9 +956,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai usageLimitsLayout.overlay > 0 ? usageLimitsLayout.overlay - usageLimitsLayout.panel : composerOverlapHeight; - // The keyboard is reserved even while closed, like the question card, so the - // panel never grows over the header while the keyboard animates. The floor - // keeps the close row on screen when the stack below leaves no room at all. + // The floor keeps the close row on screen when the stack below leaves no room at all. const usageLimitsMaxHeight = Math.max( USAGE_LIMITS_PANEL_MIN_HEIGHT, Math.min( @@ -1456,12 +1464,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai {showContent ? ( Date: Sat, 24 Oct 2026 08:11:31 +0000 Subject: [PATCH 2/3] fix(mobile): keep the question card's bounds in step with the keyboard The card now resizes every keyboard frame, so its 200ms layout transition and the eased coverage both trailed it. Drop the transition and set coverage directly. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/PendingUserInputCard.tsx | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 37992ceec94b..1086422789a6 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -8,10 +8,8 @@ import Animated, { Easing, FadeInUp, FadeOutDown, - LinearTransition, useAnimatedStyle, useSharedValue, - withTiming, type DerivedValue, type SharedValue, } from "react-native-reanimated"; @@ -65,8 +63,6 @@ export interface PendingUserInputCardProps { */ const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; -const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); - function QuestionText(props: { readonly markdown: string }) { const styles = useMarkdownPreviewStyles(); const openLink = useOpenPullRequest(); @@ -93,18 +89,9 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { if (!cardCoverage) { return; } - const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current); - if (coverage === cardCoverage.value) { - return; - } - if (cardCoverage.value === 0) { - cardCoverage.value = coverage; - return; - } - cardCoverage.value = withTiming(coverage, { - duration: USER_INPUT_TOGGLE_DURATION_MS, - easing: Easing.out(Easing.cubic), - }); + // The card resizes with the keyboard every frame, so easing each sample would leave the + // feed inset and floating controls trailing it. + cardCoverage.value = Math.max(0, cardHeightRef.current - barHeightRef.current); }, [cardCoverage]); const handleBarLayout = useCallback( (event: LayoutChangeEvent) => { @@ -197,7 +184,6 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { ? undefined : FadeOutDown.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic)) } - layout={CARD_LAYOUT_TRANSITION} className="overflow-hidden gap-2.5 rounded-[20px] border border-border bg-card-alt p-4" style={EXPANDED_CARD_IS_OVERLAY ? cardAnimatedStyle : cardMaxHeightStyle} > From b20846f4c897bb31a3c892b49855fd2b57d53c8a Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:19:56 +0000 Subject: [PATCH 3/3] fix(mobile): budget the question card from the measured screen height Android lays the screen out below its opaque header and reports a zero header height, so the window height overstates the room. The usage panel already uses the measured height for the same reason. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadDetailScreen.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 4afc699bd3f7..0fe0bb34a67c 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -894,18 +894,6 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai Platform.OS === "ios" ? deviceType !== "duo-unfolded" || (composerOwnsKeyboard && !sidebarSearchActive) : !keyboardStateSuspect; - // The question card reads the keyboard frame on the UI thread, in the same frame the sticky - // view translates, so it gets the full height while the keyboard is closed and never crosses - // the header while the keyboard moves. - const { height: keyboardTranslation } = useReanimatedKeyboardAnimation(); - const pendingUserInputMaxHeight = useDerivedValue(() => - derivePendingUserInputMaxHeight({ - windowHeight, - keyboardHeight: keyboardStickyEnabled ? -keyboardTranslation.value : 0, - navigationHeaderHeight, - composerOverlapHeight: composerBottomInset, - }), - ); const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes @@ -932,6 +920,18 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai setScreenHeight(event.nativeEvent.layout.height); }, []); const screenAreaHeight = screenHeight ?? windowHeight; + // The question card reads the keyboard frame on the UI thread, in the same frame the sticky + // view translates, so it gets the full height while the keyboard is closed and never crosses + // the header while the keyboard moves. + const { height: keyboardTranslation } = useReanimatedKeyboardAnimation(); + const pendingUserInputMaxHeight = useDerivedValue(() => + derivePendingUserInputMaxHeight({ + windowHeight: screenAreaHeight, + keyboardHeight: keyboardStickyEnabled ? -keyboardTranslation.value : 0, + navigationHeaderHeight, + composerOverlapHeight: composerBottomInset, + }), + ); // The usage panel stacks with the composer and any approval card, so it gets // whatever room they leave below the header. Measured only while it is open // so typing in the composer does not re-render the screen.