From debaa52400f79cd51e6aa8cf5bc2fd5f83cf2a10 Mon Sep 17 00:00:00 2001 From: Carlos Rico-Ospina Date: Wed, 5 Aug 2026 17:01:32 -0400 Subject: [PATCH] fix(mobile): account for the keyboard in the pending-card cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap was derived from the full window height, but the cards sit in KeyboardStickyView, which lifts the overlay by the keyboard height when a custom-answer input is focused — so a near-max card was pushed back under the navigation header while typing. It also floored at 120px, which forces the overlap it prevents when the band under the header is shorter than that, and it subtracted the composer chrome constants rather than the composer's real height, over-granting whenever a draft, attachments, or the connection pill make the composer taller. Cap the whole overlay instead of the card stack alone, subtract the keyboard height, and let flexbox place the split: the cards shrink inside the band and the composer, which is not shrinkable, keeps whatever height it measured. The floor is now that composer height, so a viewport too short for both collapses the cards rather than clipping the composer. The clamp moves to lib/layout.ts next to the other pure layout derivations, with tests for the keyboard-open and short-viewport cases. Model: Claude Opus 5. Harness: T3 Code. Co-Authored-By: Claude Opus 5 --- .../features/threads/ThreadDetailScreen.tsx | 62 ++++++++++++------- apps/mobile/src/lib/layout.test.ts | 42 +++++++++++++ apps/mobile/src/lib/layout.ts | 39 ++++++++++++ 3 files changed, 121 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ce962ff5cf33..47b887891677 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -33,14 +33,22 @@ import { View, type GestureResponderEvent, } from "react-native"; -import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; +import { + KeyboardController, + KeyboardStickyView, + useKeyboardState, +} from "react-native-keyboard-controller"; import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; -import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; +import { + CHAT_CONTENT_MAX_WIDTH, + derivePendingCardsOverlayMaxHeight, + type LayoutVariant, +} from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; import type { PendingApproval, @@ -218,22 +226,27 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; - const windowDimensions = useWindowDimensions(); - // The pending approval/user-input cards live in the bottom-anchored composer - // overlay, so a card with many questions grows upward past the navigation - // header. Cap the card stack to the space between the header and the - // composer and let it scroll instead. Read the context directly - // (useHeaderHeight throws outside a header-providing screen) and fall back - // to the standard iOS bar height. - const navigationHeaderHeight = useContext(HeaderHeightContext); - const pendingCardsMaxHeight = Math.max( - 120, - windowDimensions.height - - (navigationHeaderHeight || insets.top + 44) - - composerOverlapHeight - - 12, - ); const estimatedOverlayHeight = composerOverlapHeight; + // Pending cards live in the bottom-anchored composer overlay, so a request + // with several questions grows the overlay upward until its first questions + // sit under the navigation header, out of reach. Cap the overlay at the band + // between the header and the top of the keyboard — KeyboardStickyView lifts + // the overlay by the keyboard height — and let the cards shrink and scroll + // inside it. The composer is not shrinkable, so it keeps its natural height + // whatever the draft contains and only the cards give way. Header height + // comes from the context directly (useHeaderHeight throws outside a + // header-providing screen) with the fallback ThreadFeed already uses. + const windowHeight = useWindowDimensions().height; + const keyboardHeight = useKeyboardState((state) => state.height); + const navigationHeaderHeight = useContext(HeaderHeightContext); + const hasPendingCards = + props.activePendingApproval !== null || props.activePendingUserInput !== null; + const pendingCardsOverlayMaxHeight = derivePendingCardsOverlayMaxHeight({ + windowHeight, + navigationHeaderHeight: navigationHeaderHeight || insets.top + 44, + keyboardHeight, + composerHeight: composerOverlapHeight, + }); // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes // UIKit add the safe-area bottom to the content inset AGAIN — leaving a @@ -417,16 +430,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* No paddingTop here: the overlay's measured height becomes the list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} - - - {props.activePendingApproval || props.activePendingUserInput ? ( + + + {hasPendingCards ? ( { }); }); +describe("derivePendingCardsOverlayMaxHeight", () => { + // iPhone 17 Pro portrait: 932pt tall, 96pt large-title-less header, 60pt + // collapsed composer chrome plus a 34pt home-indicator inset. + const iphone = { + windowHeight: 932, + navigationHeaderHeight: 96, + composerHeight: 94, + } as const; + + it("leaves the cards the band between the header and the composer", () => { + expect(derivePendingCardsOverlayMaxHeight({ ...iphone, keyboardHeight: 0 })).toBe(824); + }); + + it("gives the band back to the keyboard while a custom answer is typed", () => { + expect(derivePendingCardsOverlayMaxHeight({ ...iphone, keyboardHeight: 336 })).toBe(488); + }); + + it("collapses the cards rather than clipping the composer in a short viewport", () => { + // iPhone landscape with the keyboard up: nothing is left above the composer. + expect( + derivePendingCardsOverlayMaxHeight({ + windowHeight: 430, + navigationHeaderHeight: 44, + keyboardHeight: 336, + composerHeight: 72, + }), + ).toBe(72); + }); + + it("falls back to the composer height when the window is not measured yet", () => { + expect( + derivePendingCardsOverlayMaxHeight({ + windowHeight: 0, + navigationHeaderHeight: 96, + keyboardHeight: 0, + composerHeight: 94, + }), + ).toBe(94); + }); +}); + describe("deriveStableFormSheetDetent", () => { it.each([ { height: 1_194, expected: 0.62 }, diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index eb0c45e0607d..202ae8b0f4e1 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -28,6 +28,8 @@ const STABLE_FORM_SHEET_MAX_HEIGHT = 720; const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64; const STABLE_FORM_SHEET_MIN_DETENT = 0.62; const STABLE_FORM_SHEET_MAX_DETENT = 0.92; +/** Breathing room kept between the navigation header and the pending cards. */ +const PENDING_CARDS_HEADER_GAP = 12; export type LayoutVariant = "compact" | "split"; @@ -238,6 +240,43 @@ export function deriveCenteredContentHorizontalPadding(input: { return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); } +/** + * Bound the bottom-anchored composer overlay to the band between the navigation + * header and the top of the keyboard. + * + * Pending approval/user-input cards sit above the composer inside that overlay, + * so a request carrying several questions grows it upward until its first + * questions are hidden behind the header. The composer itself is never squeezed: + * where the band cannot hold both, the cards collapse and the composer keeps the + * height it measured. + */ +export function derivePendingCardsOverlayMaxHeight(input: { + readonly windowHeight: number; + readonly navigationHeaderHeight: number; + readonly keyboardHeight: number; + readonly composerHeight: number; +}): number { + const composerHeight = Number.isFinite(input.composerHeight) + ? Math.max(0, input.composerHeight) + : 0; + + if (!Number.isFinite(input.windowHeight) || input.windowHeight <= 0) { + return composerHeight; + } + + const navigationHeaderHeight = Number.isFinite(input.navigationHeaderHeight) + ? Math.max(0, input.navigationHeaderHeight) + : 0; + const keyboardHeight = Number.isFinite(input.keyboardHeight) + ? Math.max(0, input.keyboardHeight) + : 0; + + return Math.max( + composerHeight, + input.windowHeight - navigationHeaderHeight - keyboardHeight - PENDING_CARDS_HEADER_GAP, + ); +} + export function deriveStableFormSheetDetent(containerHeight: number): number { if (!Number.isFinite(containerHeight) || containerHeight <= 0) { return STABLE_FORM_SHEET_MAX_DETENT;