Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 40 additions & 22 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. */}
<View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
<View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
{props.activePendingApproval || props.activePendingUserInput ? (
<View
ref={composerOverlayRef}
onLayout={onComposerLayout}
className="w-full"
style={hasPendingCards ? { maxHeight: pendingCardsOverlayMaxHeight } : undefined}
>
<View className="w-full shrink self-center" style={{ maxWidth: contentMaxWidth }}>
{hasPendingCards ? (
<Animated.View
className="shrink-0 px-4 pb-3"
style={{ maxHeight: pendingCardsMaxHeight }}
className="shrink px-4 pb-3"
entering={FadeInDown.duration(220)}
exiting={FadeOut.duration(140)}
>
<ScrollView
className="shrink"
contentContainerClassName="gap-3"
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
Expand Down
42 changes: 42 additions & 0 deletions apps/mobile/src/lib/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
deriveCenteredContentHorizontalPadding,
deriveFileInspectorPaneLayout,
deriveLayout,
derivePendingCardsOverlayMaxHeight,
deriveStableFormSheetDetent,
deriveWorkspacePaneLayout,
SPLIT_LAYOUT_MIN_HEIGHT,
Expand Down Expand Up @@ -331,6 +332,47 @@ describe("deriveWorkspacePaneLayout", () => {
});
});

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 },
Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/src/lib/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
Loading