From 5bf7f8b7e73dfc2354ee29ed3afa44fb5fc273a8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 15:19:10 +0100 Subject: [PATCH] fix(app): keep the header in the top band whichever sidebar is open With the left sidebar pinned open on the iPhone Duo inner screen, the workspace header dropped below the status region, so it jumped 56pt between sidebar states. Headers now always trim their trailing side to the status region and let the title give up width instead. Co-authored-by: Elsa Granger --- .../src/components/headers/compact-header.tsx | 29 ++++--------------- .../src/components/headers/screen-header.tsx | 18 +++--------- packages/app/src/window-layout/model.test.ts | 14 ++++----- 3 files changed, 17 insertions(+), 44 deletions(-) diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx index 69f73cc438..c87c6df956 100644 --- a/packages/app/src/components/headers/compact-header.tsx +++ b/packages/app/src/components/headers/compact-header.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useMemo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Text, View, type LayoutChangeEvent } from "react-native"; +import { Text, View } from "react-native"; import { router } from "expo-router"; import { ArrowLeft } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -12,7 +12,6 @@ import { iconButtonChromeGlyphSize, mutedIconColorMapping, } from "@/components/ui/icon-button-chrome"; -import { TOUCH_TARGET } from "@/components/ui/control-sizing"; import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; import { SPACING } from "@/styles/theme"; import { useTopBandRow } from "@/window-layout"; @@ -22,9 +21,6 @@ const ThemedArrowLeft = withUnistyles(ArrowLeft); const NO_SHORTCUT: ShortcutKey[] = []; const ROW_PADDING = SPACING[3]; -const ROW_GAP = SPACING[2]; -// Below this the title is unreadable; the row starts below the system's occlusions instead. -const MIN_TITLE_WIDTH = 96; interface CompactHeaderProps { /** The leading island: the sidebar menu, or a back button. */ @@ -58,21 +54,12 @@ export function CompactHeader({ actions, }: CompactHeaderProps) { const insets = useSafeAreaInsets(); - const [actionsWidth, setActionsWidth] = useState(0); - const measureActions = useCallback( - (event: LayoutChangeEvent) => setActionsWidth(event.nativeEvent.layout.width), - [], - ); // The trailing side gives way to the occlusion, so it needs no padding of its own. const topBand = useTopBandRow({ offset: insets.top + HEADER_TOP_PADDING_MOBILE, height: HEADER_INNER_HEIGHT_MOBILE, - minimumWidth: - ROW_PADDING + - TOUCH_TARGET + - ROW_GAP + - MIN_TITLE_WIDTH + - (actions ? ROW_GAP + actionsWidth : 0), + // The header stays in the band in every sidebar state; the title gives up width instead. + minimumWidth: 0, }); const rootStyle = useMemo( () => [ @@ -102,11 +89,7 @@ export function CompactHeader({ subtitle={subtitle} /> - {actions ? ( - - {actions} - - ) : null} + {actions ? {actions} : null} @@ -174,7 +157,7 @@ const styles = StyleSheet.create((theme) => ({ height: HEADER_INNER_HEIGHT_MOBILE, flexDirection: "row", alignItems: "center", - gap: ROW_GAP, + gap: theme.spacing[2], }, titleGroup: { flex: 1, diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index 2af603ade4..feabfe1ecb 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -1,5 +1,5 @@ import { ComposerDockBackground } from "@/composer/dock"; -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import type { LayoutChangeEvent } from "react-native"; import { View, type StyleProp, type ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -12,12 +12,8 @@ import { } from "@/constants/layout"; import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { TOUCH_TARGET } from "@/components/ui/control-sizing"; import { useTopBandRow } from "@/window-layout"; -// Leading content that must stay readable in the top band: a navigation button and a title. -const MIN_LEADING_WIDTH = TOUCH_TARGET + 8 + 96; - interface ScreenHeaderProps { left?: ReactNode; right?: ReactNode; @@ -46,15 +42,11 @@ export function ScreenHeader({ const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3]; - const [rightWidth, setRightWidth] = useState(0); - const measureRight = useCallback( - (event: LayoutChangeEvent) => setRightWidth(event.nativeEvent.layout.width), - [], - ); const topBand = useTopBandRow({ offset: insets.top + topPadding, height: isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT, - minimumWidth: baseHorizontalPadding + MIN_LEADING_WIDTH + rightWidth, + // The header stays in the band in every sidebar state; the title gives up width instead. + minimumWidth: 0, }); const innerStyle = useMemo( () => [styles.inner, { paddingTop: insets.top + topPadding + topBand.top }], @@ -85,9 +77,7 @@ export function ScreenHeader({ > {left} - - {right} - + {right} diff --git a/packages/app/src/window-layout/model.test.ts b/packages/app/src/window-layout/model.test.ts index 275c38e816..10065b8bf9 100644 --- a/packages/app/src/window-layout/model.test.ts +++ b/packages/app/src/window-layout/model.test.ts @@ -129,9 +129,9 @@ describe("resolveTopBand", () => { describe("resolveTopBandClearance", () => { // The full workspace header with the sidebar closed spans the window. const fullHeader = { x: 0, y: duo.topBar.y, width: 669, height: 44 }; - // The compact header beside a 320pt sidebar: 8pt top padding, 56pt row. - const compactHeader = { x: 320, y: duo.topBar.y + 8, width: 349, height: 56 }; - const compactMinimum = 12 + 44 + 8 + 96 + 8 + 132; + // A row beside a 320pt sidebar whose controls need more width than the status region leaves. + const narrowRow = { x: 320, y: duo.topBar.y + 8, width: 349, height: 56 }; + const narrowMinimum = 300; it("trims the trailing side to the status region", () => { expect( @@ -156,9 +156,9 @@ describe("resolveTopBandClearance", () => { it("starts below the status region when the controls no longer fit beside it", () => { expect( resolveTopBandClearance({ - row: compactHeader, + row: narrowRow, occlusions: [duo.statusRegion], - minimumWidth: compactMinimum, + minimumWidth: narrowMinimum, }), ).toEqual({ trailing: 0, top: 48 }); }); @@ -166,9 +166,9 @@ describe("resolveTopBandClearance", () => { it("starts below the lowest of several overlapping occlusions", () => { expect( resolveTopBandClearance({ - row: compactHeader, + row: narrowRow, occlusions: [duo.statusRegion, { x: 600, y: 0, width: 69, height: 96 }], - minimumWidth: compactMinimum, + minimumWidth: narrowMinimum, }), ).toEqual({ trailing: 0, top: 62 }); });