fix(mobile): keep content clear of the side insets in landscape (#28749)

In landscape the iPhone sensor housing and Android's side nav bar are left/right
insets, which no screen or over-the-air page padded for.

Fixes #21190
This commit is contained in:
Kelvin Amoaba
2026-10-15 00:57:56 +00:00
committed by GitHub
parent 67a3aa492d
commit 6d519f7835
13 changed files with 244 additions and 63 deletions
@@ -14,7 +14,7 @@ import {
} from './mobile-web-app-render-harness.mjs'
// The page lays out edge-to-edge under the shell's system bars and pads through its own
// SafeAreaViews, by the insets `init` carries. Measured in a real browser because what is under
// SafeAreaViews, and its root for the sides, by the insets `init` carries. Measured in a real browser because what is under
// test is which providers and navigators expo-router actually mounts above a screen.
const HOST_ROUTE = '/h/render-check-host'
const HOST = {
@@ -106,7 +106,9 @@ function readEdges(page) {
)
return {
titleTop: title === null ? null : range.getBoundingClientRect().top,
titleLeft: title === null ? null : range.getBoundingClientRect().left,
fabBottom: fab?.getBoundingClientRect().bottom ?? null,
fabRight: fab?.getBoundingClientRect().right ?? null,
envTop: (() => {
const probe = document.createElement('div')
probe.style.paddingTop = 'env(safe-area-inset-top)'
@@ -142,6 +144,19 @@ describeRender('the page pads for the system bars once, by the shell insets', ()
expect(padded.titleTop - zero.titleTop).toBe(INSETS.top)
}, 90_000)
// Landscape puts the iPhone sensor housing on a side. Unequal sides, so a swapped edge fails.
it('keeps the screens clear of the side insets, padding them once', async () => {
const upright = await openHostRoute(INSETS)
const base = await readEdges(upright.page)
await upright.page.close()
const sideways = await openHostRoute({ ...INSETS, left: 59, right: 44 })
const inset = await readEdges(sideways.page)
await sideways.page.close()
expect([...upright.errors, ...sideways.errors]).toEqual([])
expect(inset.titleLeft - base.titleLeft).toBe(59)
expect(base.fabRight - inset.fabRight).toBe(44)
}, 60_000)
it('follows a re-sent init, which is how the keyboard and rotation reach the page', async () => {
const opened = await openHostRoute(INSETS)
const before = await readEdges(opened.page)
+41 -38
View File
@@ -25,6 +25,7 @@ import { loadHostCatalog } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
import { appUpdateChecker } from '../src/app-update/app-update-runtime'
import { SideSafeArea } from '../src/layout/side-safe-area'
// Why: keeps the native splash screen visible until the React tree is mounted
// and ready to render. Without this the user sees a blank white/black frame
@@ -200,45 +201,47 @@ export default function RootLayout() {
<RpcClientProvider>
<View style={styles.root} onLayout={onNavigatorLayout}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgPanel },
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
headerShadowVisible: false
// Why: deliberately no `orientation` screenOption. react-native-screens
// has no value that respects the device rotation lock — even 'default'
// calls setRequestedOrientation(UNSPECIFIED) at runtime, overriding the
// manifest. Leaving it unset lets the manifest's "fullUser" (set by the
// android-respect-rotation-lock config plugin) honor the auto-rotate lock.
}}
>
<Stack.Screen
name="index"
options={{
headerShown: false,
headerTitle: () => <OrcaLogo size={22} />
<SideSafeArea>
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgPanel },
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
headerShadowVisible: false
// Why: deliberately no `orientation` screenOption. react-native-screens
// has no value that respects the device rotation lock — even 'default'
// calls setRequestedOrientation(UNSPECIFIED) at runtime, overriding the
// manifest. Leaving it unset lets the manifest's "fullUser" (set by the
// android-respect-rotation-lock config plugin) honor the auto-rotate lock.
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen
name="mobile-onboarding"
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
</Stack>
>
<Stack.Screen
name="index"
options={{
headerShown: false,
headerTitle: () => <OrcaLogo size={22} />
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen
name="mobile-onboarding"
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
</Stack>
</SideSafeArea>
</View>
</RpcClientProvider>
)
+5 -2
View File
@@ -3,13 +3,14 @@ import { StyleSheet, useWindowDimensions, View } from 'react-native'
import { Slot } from 'expo-router'
import { SafeAreaFrameContext, SafeAreaInsetsContext } from 'react-native-safe-area-context'
import { ZERO_SAFE_AREA_INSETS } from '../src/mobile-web-shell/bridge/bridge-safe-area-insets'
import { SideSafeArea } from '../src/layout/side-safe-area'
import { usePageBridgeClient } from '../src/transport/client-context.web'
import { colors } from '../src/theme/mobile-theme'
/**
* The page's root layout, standing where the native `_layout.tsx` does. Without one expo-router
* mounts `DefaultNavigator`, an all-edges `SafeAreaView`, which would pad each screen a second time.
* Like the native root it adds no padding. It supplies the insets the shell sent in `init` below
* Like the native root it pads only the sides. It supplies the insets the shell sent in `init` below
* ExpoRoot's own provider, whose web half reads `env(safe-area-inset-*)`: 0 in both WebViews.
*/
export default function PageRootLayout() {
@@ -24,7 +25,9 @@ export default function PageRootLayout() {
<SafeAreaFrameContext.Provider value={frame}>
<SafeAreaInsetsContext.Provider value={insets}>
<View style={styles.root}>
<Slot />
<SideSafeArea>
<Slot />
</SideSafeArea>
</View>
</SafeAreaInsetsContext.Provider>
</SafeAreaFrameContext.Provider>
+3 -3
View File
@@ -1,6 +1,6 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react'
import { View, Pressable, StyleSheet, Platform, useWindowDimensions, Keyboard } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { View, Pressable, StyleSheet, Platform, Keyboard } from 'react-native'
import { useSafeAreaFrame, useSafeAreaInsets } from 'react-native-safe-area-context'
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
useSharedValue,
@@ -78,7 +78,7 @@ function MountedRightDrawer({
}: MountedRightDrawerProps) {
const translateX = useSharedValue(0)
const progress = useSharedValue(0)
const { width: screenWidth } = useWindowDimensions()
const { width: screenWidth } = useSafeAreaFrame()
const insets = useSafeAreaInsets()
const { isWideLayout } = useResponsiveLayout()
const panelWidth = resolveRightDrawerPanelWidth(screenWidth, isWideLayout, widthPx)
@@ -1,6 +1,5 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { View, Pressable, useWindowDimensions, ScrollView, Keyboard, Modal } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
useSharedValue,
@@ -19,6 +18,7 @@ import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
import { bottomDrawerStyles as styles } from './bottom-drawer-styles'
import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
import { useResponsiveLayout } from '../layout/responsive-layout'
import { useWindowSafeAreaInsets } from '../layout/side-safe-area'
import { useBackClaim } from '../navigation/use-back-claim'
import { currentSoftKeyboardHeight, subscribeSoftKeyboard } from '../platform/keyboard-occlusion'
import { hostOs } from '../platform/host-os'
@@ -68,7 +68,7 @@ export function MountedBottomDrawer({
// edge stays under the status bar.
const [keyboardInset, setKeyboardInset] = useState(0)
const { height: screenHeight } = useWindowDimensions()
const insets = useSafeAreaInsets()
const insets = useWindowSafeAreaInsets()
// Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and
// center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
// transforms below) is unchanged, so phone behavior stays identical.
@@ -376,7 +376,14 @@ export function MountedBottomDrawer({
{interactive ? <Pressable style={styles.backdropPressable} onPress={dismiss} /> : null}
</Animated.View>
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<View
style={[
styles.anchor,
isWideLayout && styles.anchorWide,
{ paddingLeft: insets.left, paddingRight: insets.right }
]}
pointerEvents="box-none"
>
<Animated.View
// Why: remount per window hand-back — see the windowEpoch effect.
key={windowEpoch}
@@ -31,6 +31,7 @@ vi.mock('react-native', () => ({
useWindowDimensions: () => ({ width: 390, height: 844 })
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaFrame: () => ({ x: 0, y: 0, width: 390, height: 844 }),
useSafeAreaInsets: () => ({ top: 62, bottom: 34, left: 0, right: 0 })
}))
vi.mock('react-native-gesture-handler', () => {
+3 -3
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Pressable, Text, View, useWindowDimensions } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Pressable, Text, View } from 'react-native'
import { SafeAreaView, useSafeAreaFrame } from 'react-native-safe-area-context'
import { ChevronLeft, Save } from 'lucide-react-native'
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
import { getWorktreeLabel } from '../session/worktree-label'
@@ -51,7 +51,7 @@ export function MobileFilePreviewScreen({ route }: Props) {
const draftContentRef = useRef(draftContent)
const savedContentRef = useRef(savedContent)
const draftSourceKeyRef = useRef<string | null>(null)
const { width, height } = useWindowDimensions()
const { width, height } = useSafeAreaFrame()
const routePreviewSource = useMemo(
() => (previewParams ? previewSourceFromRoute(previewParams) : null),
[previewParams]
@@ -26,7 +26,10 @@ vi.mock('react-native', () => ({
View: 'View',
useWindowDimensions: () => ({ width: 390, height: 844 })
}))
vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' }))
vi.mock('react-native-safe-area-context', () => ({
SafeAreaView: 'SafeAreaView',
useSafeAreaFrame: () => ({ x: 0, y: 0, width: 390, height: 844 })
}))
vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', Save: 'Icon' }))
vi.mock('../navigation/route-handoff', () => ({
useRouteHandoff: () => ({ back: () => {}, canGoBack: () => false })
+7 -6
View File
@@ -1,5 +1,5 @@
import { QrCode } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing } from '../theme/mobile-theme'
const ONBOARDING_STEPS = [
@@ -23,9 +23,10 @@ export function MobileHomeEmptyState(props: {
isWideLayout: boolean
onPairDesktop: () => void
}) {
// Scrolls because a phone in landscape is shorter than the hero and the steps together.
return (
<View
style={[
<ScrollView
contentContainerStyle={[
styles.emptyContainer,
{ paddingBottom: props.bottomInset },
props.isWideLayout && {
@@ -60,14 +61,14 @@ export function MobileHomeEmptyState(props: {
</View>
))}
</View>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
emptyContainer: { flex: 1 },
emptyContainer: { flexGrow: 1 },
emptyHero: {
flex: 1,
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
+71
View File
@@ -0,0 +1,71 @@
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import type { EdgeInsets, Rect } from 'react-native-safe-area-context'
vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
View: 'View',
StyleSheet: { create: <T,>(styles: T) => styles }
}))
// The library's hooks are plain reads of its two contexts, which is all this stands in for.
vi.mock('react-native-safe-area-context', async () => {
const { createContext, useContext } = await import('react')
const SafeAreaInsetsContext = createContext<EdgeInsets | null>(null)
const SafeAreaFrameContext = createContext<Rect | null>(null)
return {
SafeAreaInsetsContext,
SafeAreaFrameContext,
SafeAreaProvider: ({ children }: { children: ReactNode }) => children,
useSafeAreaInsets: () => useContext(SafeAreaInsetsContext),
useSafeAreaFrame: () => useContext(SafeAreaFrameContext)
}
})
const { SafeAreaFrameContext, SafeAreaInsetsContext, useSafeAreaFrame, useSafeAreaInsets } =
await import('react-native-safe-area-context')
const { SideSafeArea, useWindowSafeAreaInsets } = await import('./side-safe-area')
// Unequal sides, so a swapped edge shows.
const WINDOW = { top: 0, right: 48, bottom: 21, left: 59 }
const FRAME = { x: 0, y: 0, width: 852, height: 393 }
function renderInLandscape() {
const read: { screen?: EdgeInsets; frame?: Rect; modal?: EdgeInsets } = {}
function Probe() {
read.screen = useSafeAreaInsets()
read.frame = useSafeAreaFrame()
read.modal = useWindowSafeAreaInsets()
return null
}
let tree: ReactTestRenderer | undefined
act(() => {
tree = create(
<SafeAreaFrameContext.Provider value={FRAME}>
<SafeAreaInsetsContext.Provider value={WINDOW}>
<SideSafeArea>
<Probe />
</SideSafeArea>
</SafeAreaInsetsContext.Provider>
</SafeAreaFrameContext.Provider>
)
})
return { tree: tree!, read }
}
describe('the app laid out clear of the side insets', () => {
it('pads the sides once, and screens below see the padded area', () => {
const { tree, read } = renderInLandscape()
const area = tree.root.find((node) => String(node.type) === 'View')
expect(Object.assign({}, ...[area.props.style].flat())).toMatchObject({
paddingLeft: 59,
paddingRight: 48
})
expect(read.screen).toEqual({ top: 0, right: 0, bottom: 21, left: 0 })
expect(read.frame).toEqual({ x: 59, y: 0, width: 745, height: 393 })
})
it('gives a Modal, which opens a window of its own, the window insets', () => {
expect(renderInLandscape().read.modal).toEqual(WINDOW)
})
})
+57
View File
@@ -0,0 +1,57 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { Platform, StyleSheet, View } from 'react-native'
import {
SafeAreaFrameContext,
SafeAreaInsetsContext,
SafeAreaProvider,
useSafeAreaFrame,
useSafeAreaInsets,
type EdgeInsets
} from 'react-native-safe-area-context'
const WindowSafeAreaInsetsContext = createContext<EdgeInsets | null>(null)
/**
* Lays the app out clear of the window's side insets, which landscape brings: the iPhone sensor
* housing, an Android side navigation bar or cutout. Below it the insets and frame describe the
* padded area, so no screen pads a side twice or sizes past it.
*/
export function SideSafeArea({ children }: { children: ReactNode }) {
const insets = useSafeAreaInsets()
const frame = useSafeAreaFrame()
const areaInsets = useMemo(() => ({ ...insets, left: 0, right: 0 }), [insets])
const areaFrame = useMemo(
() => ({
...frame,
x: frame.x + insets.left,
width: Math.max(0, frame.width - insets.left - insets.right)
}),
[frame, insets.left, insets.right]
)
const area = (
<WindowSafeAreaInsetsContext.Provider value={insets}>
<SafeAreaFrameContext.Provider value={areaFrame}>
<SafeAreaInsetsContext.Provider value={areaInsets}>
{children}
</SafeAreaInsetsContext.Provider>
</SafeAreaFrameContext.Provider>
</WindowSafeAreaInsetsContext.Provider>
)
// A native SafeAreaView measures against the nearest native provider rather than the context, so
// one goes inside the padding; on the web it reads the context.
return (
<View style={[styles.area, { paddingLeft: insets.left, paddingRight: insets.right }]}>
{Platform.OS === 'web' ? area : <SafeAreaProvider>{area}</SafeAreaProvider>}
</View>
)
}
/** The window's insets, for a Modal: it opens a window of its own, outside `SideSafeArea`. */
export function useWindowSafeAreaInsets(): EdgeInsets {
const insets = useSafeAreaInsets()
return useContext(WindowSafeAreaInsetsContext) ?? insets
}
const styles = StyleSheet.create({
area: { flex: 1 }
})
@@ -1,11 +1,11 @@
import { useRef, useState } from 'react'
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Copy, TextSelect, X } from 'lucide-react-native'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { ActionSheetContent } from '../components/ActionSheetModal'
import { BottomDrawer } from '../components/BottomDrawer'
import { useClipboardWriter } from '../platform/clipboard'
import { useWindowSafeAreaInsets } from '../layout/side-safe-area'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text'
@@ -75,10 +75,20 @@ function SelectTextScreen({
text: string
onClose: () => void
}): React.JSX.Element {
const insets = useSafeAreaInsets()
const insets = useWindowSafeAreaInsets()
return (
<Modal visible animationType="slide" onRequestClose={onClose}>
<View style={[styles.screen, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View
style={[
styles.screen,
{
paddingTop: insets.top,
paddingRight: insets.right,
paddingBottom: insets.bottom,
paddingLeft: insets.left
}
]}
>
<View style={styles.header}>
<Text style={styles.title}>Select text</Text>
<Pressable
+13 -3
View File
@@ -1,8 +1,8 @@
import { memo, useCallback, useMemo, useState } from 'react'
import { ActivityIndicator, Modal, Pressable, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Maximize2, X } from 'lucide-react-native'
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive'
import { useWindowSafeAreaInsets } from '../layout/side-safe-area'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read'
import { MOBILE_NATIVE_CHAT_VISUAL_INITIAL_HEIGHT } from './mobile-native-chat-visual-host-document'
@@ -113,10 +113,20 @@ function MobileNativeChatVisualFullscreen({
onClose: () => void
onFailed: () => void
}) {
const insets = useSafeAreaInsets()
const insets = useWindowSafeAreaInsets()
return (
<Modal visible animationType="slide" presentationStyle="fullScreen" onRequestClose={onClose}>
<View style={[styles.sheet, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View
style={[
styles.sheet,
{
paddingTop: insets.top,
paddingRight: insets.right,
paddingBottom: insets.bottom,
paddingLeft: insets.left
}
]}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle} numberOfLines={1}>
{title}