From 6d519f7835a880936b207c698d1fccf4f7a7339c Mon Sep 17 00:00:00 2001
From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
Date: Thu, 15 Oct 2026 00:57:56 +0000
Subject: [PATCH] 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
---
.../mobile-web-app-safe-area-render.test.mjs | 17 +++-
mobile/app/_layout.tsx | 79 ++++++++++---------
mobile/app/_layout.web.tsx | 7 +-
mobile/src/components/RightDrawer.tsx | 6 +-
.../src/components/mounted-bottom-drawer.tsx | 13 ++-
.../right-drawer-hardware-back.test.tsx | 1 +
mobile/src/files/MobileFilePreviewScreen.tsx | 6 +-
.../files/mobile-file-preview-retry.test.tsx | 5 +-
mobile/src/home/MobileHomeEmptyState.tsx | 13 +--
mobile/src/layout/side-safe-area.test.tsx | 71 +++++++++++++++++
mobile/src/layout/side-safe-area.tsx | 57 +++++++++++++
.../MobileNativeChatMessageActionsSheet.tsx | 16 +++-
mobile/src/session/MobileNativeChatVisual.tsx | 16 +++-
13 files changed, 244 insertions(+), 63 deletions(-)
create mode 100644 mobile/src/layout/side-safe-area.test.tsx
create mode 100644 mobile/src/layout/side-safe-area.tsx
diff --git a/config/scripts/mobile-web-app-safe-area-render.test.mjs b/config/scripts/mobile-web-app-safe-area-render.test.mjs
index 5a3c5260afc..f311a45bca9 100644
--- a/config/scripts/mobile-web-app-safe-area-render.test.mjs
+++ b/config/scripts/mobile-web-app-safe-area-render.test.mjs
@@ -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)
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index 8b9a62c8391..ad7ee9ccee0 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -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() {
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ >
+
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
diff --git a/mobile/app/_layout.web.tsx b/mobile/app/_layout.web.tsx
index e33906939aa..7b20142574d 100644
--- a/mobile/app/_layout.web.tsx
+++ b/mobile/app/_layout.web.tsx
@@ -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() {
-
+
+
+
diff --git a/mobile/src/components/RightDrawer.tsx b/mobile/src/components/RightDrawer.tsx
index 86d2709cc0a..f8de3a9dc3f 100644
--- a/mobile/src/components/RightDrawer.tsx
+++ b/mobile/src/components/RightDrawer.tsx
@@ -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)
diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx
index 764a3c5437e..2d2f3e8a371 100644
--- a/mobile/src/components/mounted-bottom-drawer.tsx
+++ b/mobile/src/components/mounted-bottom-drawer.tsx
@@ -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 ? : null}
-
+
({
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', () => {
diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx
index e71da8e2833..973751491a2 100644
--- a/mobile/src/files/MobileFilePreviewScreen.tsx
+++ b/mobile/src/files/MobileFilePreviewScreen.tsx
@@ -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(null)
- const { width, height } = useWindowDimensions()
+ const { width, height } = useSafeAreaFrame()
const routePreviewSource = useMemo(
() => (previewParams ? previewSourceFromRoute(previewParams) : null),
[previewParams]
diff --git a/mobile/src/files/mobile-file-preview-retry.test.tsx b/mobile/src/files/mobile-file-preview-retry.test.tsx
index 46ed986c1bc..54d626d8283 100644
--- a/mobile/src/files/mobile-file-preview-retry.test.tsx
+++ b/mobile/src/files/mobile-file-preview-retry.test.tsx
@@ -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 })
diff --git a/mobile/src/home/MobileHomeEmptyState.tsx b/mobile/src/home/MobileHomeEmptyState.tsx
index eda2496094d..e5d9c6b826e 100644
--- a/mobile/src/home/MobileHomeEmptyState.tsx
+++ b/mobile/src/home/MobileHomeEmptyState.tsx
@@ -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 (
-
))}
-
+
)
}
const styles = StyleSheet.create({
- emptyContainer: { flex: 1 },
+ emptyContainer: { flexGrow: 1 },
emptyHero: {
- flex: 1,
+ flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
diff --git a/mobile/src/layout/side-safe-area.test.tsx b/mobile/src/layout/side-safe-area.test.tsx
new file mode 100644
index 00000000000..a46f4122004
--- /dev/null
+++ b/mobile/src/layout/side-safe-area.test.tsx
@@ -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: (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(null)
+ const SafeAreaFrameContext = createContext(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(
+
+
+
+
+
+
+
+ )
+ })
+ 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)
+ })
+})
diff --git a/mobile/src/layout/side-safe-area.tsx b/mobile/src/layout/side-safe-area.tsx
new file mode 100644
index 00000000000..1bab737976a
--- /dev/null
+++ b/mobile/src/layout/side-safe-area.tsx
@@ -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(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 = (
+
+
+
+ {children}
+
+
+
+ )
+ // 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 (
+
+ {Platform.OS === 'web' ? area : {area}}
+
+ )
+}
+
+/** 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 }
+})
diff --git a/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx b/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx
index 4d9cf71c6ba..b8f1fa908de 100644
--- a/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx
+++ b/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx
@@ -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 (
-
+
Select text
void
onFailed: () => void
}) {
- const insets = useSafeAreaInsets()
+ const insets = useWindowSafeAreaInsets()
return (
-
+
{title}