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 <zeyugao@outlook.com>
This commit is contained in:
Mohamed Boudra
2026-11-06 15:19:10 +01:00
co-authored by Elsa Granger
parent 142b2d2bc4
commit 5bf7f8b7e7
3 changed files with 17 additions and 44 deletions
@@ -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}
/>
</View>
{actions ? (
<View style={styles.island} onLayout={measureActions}>
{actions}
</View>
) : null}
{actions ? <View style={styles.island}>{actions}</View> : null}
</View>
</View>
</IconButtonChromeProvider>
@@ -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,
@@ -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({
>
<TitlebarDragRegion />
<View style={leftCombinedStyle}>{left}</View>
<View style={rightCombinedStyle} onLayout={measureRight}>
{right}
</View>
<View style={rightCombinedStyle}>{right}</View>
</WindowChromeSafeArea>
</View>
</ComposerDockBackground>
+7 -7
View File
@@ -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 });
});