From 953d0eef2f9b404373f81b79cc86f03f727fbcfe Mon Sep 17 00:00:00 2001 From: none23 Date: Mon, 2 Nov 2026 10:51:23 +0400 Subject: [PATCH 1/4] fix(mobile): keep One UI navigation and compose on one row --- .../ScrollComposeButton.android.tsx | 11 +++++-- .../features/home/AndroidHomeFab.android.tsx | 19 ++---------- .../features/home/AndroidHomeFab.shared.tsx | 1 - .../layout/AdaptiveWorkspaceLayout.tsx | 5 +-- .../threads/ThreadNavigationSidebar.tsx | 11 ++----- .../AndroidWorkspaceNavigation.android.tsx | 6 ++-- .../AndroidWorkspaceSidebar.android.tsx | 7 ++--- .../workspace/WorkspaceRootScreen.android.tsx | 16 +++------- .../workspace/floating-workspace-tabs.test.ts | 23 +++----------- .../workspace/floating-workspace-tabs.ts | 31 +++++-------------- 10 files changed, 36 insertions(+), 94 deletions(-) diff --git a/apps/mobile/src/components/ScrollComposeButton.android.tsx b/apps/mobile/src/components/ScrollComposeButton.android.tsx index d107d9be5d8a0..18068d3241bca 100644 --- a/apps/mobile/src/components/ScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/ScrollComposeButton.android.tsx @@ -10,7 +10,11 @@ import { import { useState } from "react"; import { I18nManager, Pressable, View, type StyleProp, type ViewStyle } from "react-native"; import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { + useAppearancePreferences, + useAndroidFloatingTabs, +} from "../features/settings/appearance/AppearancePreferencesProvider"; +import { FLOATING_TABS } from "../features/workspace/floating-workspace-tabs"; import { resolveScaledTextRole } from "../lib/appearancePreferences"; /** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */ @@ -22,7 +26,10 @@ export function ScrollComposeButton(props: { }) { const { appearance, themeVariables: colors } = useAppearancePreferences(); const typography = resolveScaledTextRole("footnote", appearance.baseFontSize); - const { iconSize, fabSize } = useAndroidControlSizing(); + const sizing = useAndroidControlSizing(); + const floatingTabsEnabled = useAndroidFloatingTabs(); + const iconSize = floatingTabsEnabled ? FLOATING_TABS.iconSize : sizing.iconSize; + const fabSize = floatingTabsEnabled ? FLOATING_TABS.height : sizing.fabSize; const nativeSize = Math.max(56, fabSize); const scale = fabSize / nativeSize; const nativeIconSize = Math.round(iconSize / scale); diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index 1a54ed4ad46c3..4e5724a430c08 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -14,10 +14,8 @@ import { import { useWorkspaceState } from "../../state/workspace"; import { FabScrollContext } from "./FabScrollContext"; import { updateFabScroll } from "./fab-scroll"; -import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; import { WorkspaceTabsContext, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; +import { FLOATING_TABS } from "../workspace/floating-workspace-tabs"; import { useAppearancePreferences, useAndroidFloatingTabs, @@ -31,20 +29,9 @@ export function AndroidHomeFabLayout(props: ComponentProps(null); const { state } = useWorkspaceState(); const [expanded, setExpanded] = useState(true); - const { scale, fabSize } = useAndroidControlSizing(); - const tabs = useEnabledNavigationTabs(); - const floating = floatingTabsMetrics( - tabs.length + 1, - scale, - fabSize, - props.paneWidth ?? layoutWidth ?? undefined, - ); const workspace = useWorkspaceTabs(); const bottomClearance = - insets.bottom + - FLOATING_TABS.bottom + - FLOATING_TABS.edge + - Math.max(floating.height, floating.fabOffset + fabSize); + insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + FLOATING_TABS.height; const context = floatingTabsEnabled && workspace ? { ...workspace, bottomClearance } : workspace; const scrollState = useRef({ anchor: 0, expanded: true }); const onScroll = (event: NativeSyntheticEvent) => { @@ -79,7 +66,7 @@ export function AndroidHomeFabLayout(props: ComponentProps{props.children}; } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 517d3ddf2981b..5585fb3f09d5d 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -61,7 +61,6 @@ import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; -import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; @@ -303,8 +302,7 @@ function AdaptiveWorkspaceLayoutContent( const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView && !floatingTabsEnabled; const enabledTabs = useEnabledNavigationTabs(); - const { scale, fabSize } = useAndroidControlSizing(); - const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1, scale, fabSize); + const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1); const navigationRailWidth = shouldRenderNavigationRail && (showPrimarySidebar || workspacePage) ? ANDROID_WORKSPACE_RAIL_WIDTH @@ -935,7 +933,6 @@ function AdaptiveWorkspaceLayoutContent( state.isVisible); const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); - const { scale, fabSize } = useAndroidControlSizing(); + const { scale } = useAndroidControlSizing(); const window = useWindowDimensions(); const rail = Boolean(props.rail); const floating = !rail && Boolean(props.floating); @@ -99,7 +99,7 @@ export function AndroidWorkspaceNavigation(props: { const barWidth = props.paneWidth ?? window.width - (Math.max(insets.left, FLOATING_TABS.edge) - FLOATING_TABS.edge); - const metrics = floatingTabsMetrics(tabs.length, scale, fabSize, barWidth, props.fillWidth); + const metrics = floatingTabsMetrics(tabs.length, barWidth, props.fillWidth); if (!rail && keyboardVisible) return null; return ( @@ -120,7 +120,7 @@ export function AndroidWorkspaceNavigation(props: { tabs={tabs} selectedTab={selectedTab} dark={themeAppearance === "dark"} - textScale={scale} + textScale={floating ? 1 : scale} newEnabled={connectionState.hasConnections} safeAreaTop={toolbar.paddingTop} safeAreaBottom={insets.bottom} diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx index 096e01bf9edec..5214e5ebde735 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx @@ -4,8 +4,7 @@ import { useEnabledNavigationTabs } from "./navigation-tabs"; import { NAVIGATION_TAB_ORDER } from "./navigation-tab-order"; import { type ReactNode } from "react"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { FLOATING_TABS, floatingTabsMetrics } from "./floating-workspace-tabs"; +import { FLOATING_TABS } from "./floating-workspace-tabs"; import { View } from "react-native"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; @@ -22,10 +21,8 @@ export function AndroidWorkspaceSidebar(props: { const workspace = useWorkspaceTabs()!; const enabled = useEnabledNavigationTabs(); const insets = useSafeAreaInsets(); - const { scale, fabSize } = useAndroidControlSizing(); - const floating = floatingTabsMetrics(enabled.length + 1, scale, fabSize); const bottomClearance = floatingTabsEnabled - ? insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + floating.height + ? insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + FLOATING_TABS.height : 16; const context = { ...workspace, bottomClearance }; if (enabled.length === 0) return ; diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx index 1fa6a87bf596a..a4b99c0f1f47d 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx @@ -3,7 +3,7 @@ import { WorkspaceNoTabs } from "./WorkspaceNoTabs"; import { useEnabledNavigationTabs } from "./navigation-tabs"; import { NAVIGATION_TAB_ORDER } from "./navigation-tab-order"; import { WorkspaceExtraTab } from "./WorkspaceExtraTab"; -import { useWindowDimensions, View } from "react-native"; +import { View } from "react-native"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; @@ -15,7 +15,7 @@ import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; import { PullRequestToolbar } from "../pull-requests/PullRequestToolbar.android"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { AndroidWorkspaceNavigation, FloatingWorkspaceTabs } from "./AndroidWorkspaceNavigation"; -import { FLOATING_TABS, floatingTabsMetrics } from "./floating-workspace-tabs"; +import { FLOATING_TABS } from "./floating-workspace-tabs"; import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; import { WorkspaceTabPanes } from "./WorkspaceTabPanes"; import type { WorkspaceTab } from "./workspace-tabs-state"; @@ -28,21 +28,13 @@ export function WorkspaceRootScreen() { const workspace = useWorkspaceTabs()!; const enabled = useEnabledNavigationTabs(); const insets = useSafeAreaInsets(); - const { fabClearance, scale, fabSize } = useAndroidControlSizing(); - const window = useWindowDimensions(); - const floating = floatingTabsMetrics(enabled.length + 1, scale, fabSize, window.width); + const { fabClearance } = useAndroidControlSizing(); const context = { ...workspace, bottomClearance: layout.usesSplitView ? workspace.bottomClearance : floatingTabsEnabled - ? insets.bottom + - FLOATING_TABS.bottom + - FLOATING_TABS.edge + - Math.max( - floating.height, - workspace.state.selectedTab === "threads" ? floating.fabOffset + fabSize : 0, - ) + ? insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + FLOATING_TABS.height : workspace.state.selectedTab === "threads" ? fabClearance + 16 : 16, diff --git a/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts b/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts index dd4aadb11605d..6d994b4f0077b 100644 --- a/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts +++ b/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts @@ -19,23 +19,8 @@ it.each([ }, ); -it.each([ - [475, 1, 56], - [411, 1, 56], - [475, 1.25, 70], -] as const)( - "fills a %sdp cover row at scale %s with a %sdp compose button", - (width, scale, fabSize) => { - const metrics = floatingTabsMetrics(4, scale, fabSize, width, true); - expect(metrics.sharesRow).toBe(true); - expect(metrics.pill + 2 * 16 + 12 + fabSize).toBe(width); - expect(metrics.height).toBe(fabSize); - }, -); - -it("stacks compose above a full-width pill when a cover row is too narrow", () => { - const metrics = floatingTabsMetrics(4, 1, 56, 300, true); - expect(metrics.sharesRow).toBe(false); - expect(metrics.pill).toBe(268); - expect(metrics.fabOffset).toBe(68); +it.each([475, 411, 300])("fills a %sdp cover row beside the compose button", (width) => { + const metrics = floatingTabsMetrics(4, width, true); + expect(metrics.pill + 2 * 16 + 12 + 56).toBe(width); + expect(metrics.height).toBe(56); }); diff --git a/apps/mobile/src/features/workspace/floating-workspace-tabs.ts b/apps/mobile/src/features/workspace/floating-workspace-tabs.ts index bb24d3478fdf2..2855c4633dcde 100644 --- a/apps/mobile/src/features/workspace/floating-workspace-tabs.ts +++ b/apps/mobile/src/features/workspace/floating-workspace-tabs.ts @@ -1,30 +1,13 @@ // Keep the native drawing inset out of the visible size and edge margins. -export const FLOATING_TABS = { edge: 16, bottom: 6, gap: 12, shadow: 8 }; +export const FLOATING_TABS = { edge: 16, bottom: 6, gap: 12, shadow: 8, height: 56, iconSize: 24 }; -export function floatingTabsMetrics( - count: number, - scale: number, - fabSize: number, - paneWidth?: number, - fillWidth = false, -) { - const unit = Math.max(1, scale); - const height = fabSize; - const beside = 2 * FLOATING_TABS.edge + FLOATING_TABS.gap + fabSize; - const row = beside + count * 60 * unit; - const sharesRow = paneWidth === undefined || paneWidth >= row; - const room = - paneWidth === undefined - ? count * 60 * unit - : sharesRow - ? paneWidth - beside - : paneWidth - 2 * FLOATING_TABS.edge; +export function floatingTabsMetrics(count: number, paneWidth?: number, fillWidth = false) { + const beside = 2 * FLOATING_TABS.edge + FLOATING_TABS.gap + FLOATING_TABS.height; + const room = paneWidth === undefined ? count * 60 : paneWidth - beside; return { // Reserve the compose slot even while its button is hidden. - pill: Math.max(0, fillWidth ? room : Math.min(count * 72 * unit, room)), - height, - row, - sharesRow, - fabOffset: sharesRow ? (height - fabSize) / 2 : height + FLOATING_TABS.gap, + pill: Math.max(0, fillWidth ? room : Math.min(count * 72, room)), + height: FLOATING_TABS.height, + row: beside + count * 60, }; } From 6d291f9a908f01b44582692b7a799a0cd0e56cc0 Mon Sep 17 00:00:00 2001 From: none23 Date: Mon, 2 Nov 2026 11:54:39 +0400 Subject: [PATCH 2/4] fix(mobile): preserve text scaling in one-row navigation --- .../ScrollComposeButton.android.tsx | 11 ++-------- .../features/home/AndroidHomeFab.android.tsx | 5 +++-- .../layout/AdaptiveWorkspaceLayout.tsx | 4 +++- .../threads/ThreadNavigationSidebar.tsx | 4 ++-- .../AndroidWorkspaceNavigation.android.tsx | 6 +++--- .../AndroidWorkspaceSidebar.android.tsx | 4 +++- .../workspace/WorkspaceRootScreen.android.tsx | 4 ++-- .../workspace/floating-workspace-tabs.test.ts | 21 ++++++++++++++----- .../workspace/floating-workspace-tabs.ts | 21 ++++++++++++------- 9 files changed, 48 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/components/ScrollComposeButton.android.tsx b/apps/mobile/src/components/ScrollComposeButton.android.tsx index 18068d3241bca..d107d9be5d8a0 100644 --- a/apps/mobile/src/components/ScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/ScrollComposeButton.android.tsx @@ -10,11 +10,7 @@ import { import { useState } from "react"; import { I18nManager, Pressable, View, type StyleProp, type ViewStyle } from "react-native"; import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; -import { - useAppearancePreferences, - useAndroidFloatingTabs, -} from "../features/settings/appearance/AppearancePreferencesProvider"; -import { FLOATING_TABS } from "../features/workspace/floating-workspace-tabs"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { resolveScaledTextRole } from "../lib/appearancePreferences"; /** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */ @@ -26,10 +22,7 @@ export function ScrollComposeButton(props: { }) { const { appearance, themeVariables: colors } = useAppearancePreferences(); const typography = resolveScaledTextRole("footnote", appearance.baseFontSize); - const sizing = useAndroidControlSizing(); - const floatingTabsEnabled = useAndroidFloatingTabs(); - const iconSize = floatingTabsEnabled ? FLOATING_TABS.iconSize : sizing.iconSize; - const fabSize = floatingTabsEnabled ? FLOATING_TABS.height : sizing.fabSize; + const { iconSize, fabSize } = useAndroidControlSizing(); const nativeSize = Math.max(56, fabSize); const scale = fabSize / nativeSize; const nativeIconSize = Math.round(iconSize / scale); diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index 4e5724a430c08..728b7ecb34708 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -14,6 +14,7 @@ import { import { useWorkspaceState } from "../../state/workspace"; import { FabScrollContext } from "./FabScrollContext"; import { updateFabScroll } from "./fab-scroll"; +import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { WorkspaceTabsContext, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { FLOATING_TABS } from "../workspace/floating-workspace-tabs"; import { @@ -29,9 +30,9 @@ export function AndroidHomeFabLayout(props: ComponentProps(null); const { state } = useWorkspaceState(); const [expanded, setExpanded] = useState(true); + const { fabSize } = useAndroidControlSizing(); const workspace = useWorkspaceTabs(); - const bottomClearance = - insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + FLOATING_TABS.height; + const bottomClearance = insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + fabSize; const context = floatingTabsEnabled && workspace ? { ...workspace, bottomClearance } : workspace; const scrollState = useRef({ anchor: 0, expanded: true }); const onScroll = (event: NativeSyntheticEvent) => { diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 5585fb3f09d5d..137608d6ffd2b 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -61,6 +61,7 @@ import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; +import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; @@ -302,7 +303,8 @@ function AdaptiveWorkspaceLayoutContent( const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView && !floatingTabsEnabled; const enabledTabs = useEnabledNavigationTabs(); - const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1); + const { scale, fabSize } = useAndroidControlSizing(); + const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1, scale, fabSize); const navigationRailWidth = shouldRenderNavigationRail && (showPrimarySidebar || workspacePage) ? ANDROID_WORKSPACE_RAIL_WIDTH diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 50038eb01646f..3bbc39d5b9a0e 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -151,7 +151,7 @@ function ThreadNavigationSidebarPane( const drawerColor = materialTheme["--color-drawer"]; const insets = useSafeAreaInsets(); - const { fabClearance } = useAndroidControlSizing(); + const { fabClearance, fabSize } = useAndroidControlSizing(); const projects = useProjects(); const threads = useNavigationThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); @@ -823,7 +823,7 @@ function ThreadNavigationSidebarPane( paddingBottom: Platform.OS === "android" ? floatingTabsEnabled - ? FLOATING_TABS.bottom + FLOATING_TABS.edge + FLOATING_TABS.height + ? FLOATING_TABS.bottom + FLOATING_TABS.edge + fabSize : Math.max(insets.bottom, 16) + fabClearance - insets.bottom : 16 + insets.bottom, paddingTop: Platform.OS === "android" ? 6 : topListInset, diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx index 26fb578c5b411..247f7e00a5b3a 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx @@ -81,7 +81,7 @@ export function AndroidWorkspaceNavigation(props: { const connectionState = useWorkspaceConnectionState(); const keyboardVisible = useKeyboardState((state) => state.isVisible); const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); - const { scale } = useAndroidControlSizing(); + const { scale, fabSize } = useAndroidControlSizing(); const window = useWindowDimensions(); const rail = Boolean(props.rail); const floating = !rail && Boolean(props.floating); @@ -99,7 +99,7 @@ export function AndroidWorkspaceNavigation(props: { const barWidth = props.paneWidth ?? window.width - (Math.max(insets.left, FLOATING_TABS.edge) - FLOATING_TABS.edge); - const metrics = floatingTabsMetrics(tabs.length, barWidth, props.fillWidth); + const metrics = floatingTabsMetrics(tabs.length, scale, fabSize, barWidth, props.fillWidth); if (!rail && keyboardVisible) return null; return ( @@ -120,7 +120,7 @@ export function AndroidWorkspaceNavigation(props: { tabs={tabs} selectedTab={selectedTab} dark={themeAppearance === "dark"} - textScale={floating ? 1 : scale} + textScale={scale} newEnabled={connectionState.hasConnections} safeAreaTop={toolbar.paddingTop} safeAreaBottom={insets.bottom} diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx index 5214e5ebde735..b3fde50743760 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx @@ -4,6 +4,7 @@ import { useEnabledNavigationTabs } from "./navigation-tabs"; import { NAVIGATION_TAB_ORDER } from "./navigation-tab-order"; import { type ReactNode } from "react"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { FLOATING_TABS } from "./floating-workspace-tabs"; import { View } from "react-native"; import { ScreenContent } from "../../components/ui/ScreenContent"; @@ -21,8 +22,9 @@ export function AndroidWorkspaceSidebar(props: { const workspace = useWorkspaceTabs()!; const enabled = useEnabledNavigationTabs(); const insets = useSafeAreaInsets(); + const { fabSize } = useAndroidControlSizing(); const bottomClearance = floatingTabsEnabled - ? insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + FLOATING_TABS.height + ? insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + fabSize : 16; const context = { ...workspace, bottomClearance }; if (enabled.length === 0) return ; diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx index a4b99c0f1f47d..a5849398a843d 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx @@ -28,13 +28,13 @@ export function WorkspaceRootScreen() { const workspace = useWorkspaceTabs()!; const enabled = useEnabledNavigationTabs(); const insets = useSafeAreaInsets(); - const { fabClearance } = useAndroidControlSizing(); + const { fabClearance, fabSize } = useAndroidControlSizing(); const context = { ...workspace, bottomClearance: layout.usesSplitView ? workspace.bottomClearance : floatingTabsEnabled - ? insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + FLOATING_TABS.height + ? insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + fabSize : workspace.state.selectedTab === "threads" ? fabClearance + 16 : 16, diff --git a/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts b/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts index 6d994b4f0077b..e31a6349f662c 100644 --- a/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts +++ b/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts @@ -19,8 +19,19 @@ it.each([ }, ); -it.each([475, 411, 300])("fills a %sdp cover row beside the compose button", (width) => { - const metrics = floatingTabsMetrics(4, width, true); - expect(metrics.pill + 2 * 16 + 12 + 56).toBe(width); - expect(metrics.height).toBe(56); -}); +it.each([ + [475, 1, 56], + [411, 1, 56], + [475, 1.25, 70], + [320, 11 / 16, 48], + [320, 1, 56], + [320, 22 / 16, 77], + [300, 22 / 16, 77], +] as const)( + "fills a %sdp row at scale %s beside a %sdp compose button", + (width, scale, fabSize) => { + const metrics = floatingTabsMetrics(4, scale, fabSize, width, true); + expect(metrics.pill + 2 * 16 + 12 + fabSize).toBe(width); + expect(metrics.height).toBe(fabSize); + }, +); diff --git a/apps/mobile/src/features/workspace/floating-workspace-tabs.ts b/apps/mobile/src/features/workspace/floating-workspace-tabs.ts index 2855c4633dcde..2957e7c44f14a 100644 --- a/apps/mobile/src/features/workspace/floating-workspace-tabs.ts +++ b/apps/mobile/src/features/workspace/floating-workspace-tabs.ts @@ -1,13 +1,20 @@ // Keep the native drawing inset out of the visible size and edge margins. -export const FLOATING_TABS = { edge: 16, bottom: 6, gap: 12, shadow: 8, height: 56, iconSize: 24 }; +export const FLOATING_TABS = { edge: 16, bottom: 6, gap: 12, shadow: 8 }; -export function floatingTabsMetrics(count: number, paneWidth?: number, fillWidth = false) { - const beside = 2 * FLOATING_TABS.edge + FLOATING_TABS.gap + FLOATING_TABS.height; - const room = paneWidth === undefined ? count * 60 : paneWidth - beside; +export function floatingTabsMetrics( + count: number, + scale: number, + fabSize: number, + paneWidth?: number, + fillWidth = false, +) { + const unit = Math.max(1, scale); + const beside = 2 * FLOATING_TABS.edge + FLOATING_TABS.gap + fabSize; + const room = paneWidth === undefined ? count * 60 * unit : paneWidth - beside; return { // Reserve the compose slot even while its button is hidden. - pill: Math.max(0, fillWidth ? room : Math.min(count * 72, room)), - height: FLOATING_TABS.height, - row: beside + count * 60, + pill: Math.max(0, fillWidth ? room : Math.min(count * 72 * unit, room)), + height: fabSize, + row: beside + count * 60 * unit, }; } From 4d4bbf950f0b2114fed5963161b31e938f301333 Mon Sep 17 00:00:00 2001 From: none23 Date: Mon, 2 Nov 2026 12:29:06 +0400 Subject: [PATCH 3/4] fix(mobile): cap compose size to preserve navigation width --- .../ScrollComposeButton.android.tsx | 4 ++- .../features/home/AndroidHomeFab.android.tsx | 21 +++++++++++----- .../workspace/floating-workspace-tabs.test.ts | 25 +++++++++++-------- .../workspace/floating-workspace-tabs.ts | 10 ++++++-- 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/components/ScrollComposeButton.android.tsx b/apps/mobile/src/components/ScrollComposeButton.android.tsx index d107d9be5d8a0..13aeb462bdf5f 100644 --- a/apps/mobile/src/components/ScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/ScrollComposeButton.android.tsx @@ -17,12 +17,14 @@ import { resolveScaledTextRole } from "../lib/appearancePreferences"; export function ScrollComposeButton(props: { readonly expanded: boolean; readonly onPress: () => void; + readonly size?: number; readonly className?: string; readonly style?: StyleProp; }) { const { appearance, themeVariables: colors } = useAppearancePreferences(); const typography = resolveScaledTextRole("footnote", appearance.baseFontSize); - const { iconSize, fabSize } = useAndroidControlSizing(); + const { iconSize, fabSize: scaledFabSize } = useAndroidControlSizing(); + const fabSize = props.size ?? scaledFabSize; const nativeSize = Math.max(56, fabSize); const scale = fabSize / nativeSize; const nativeIconSize = Math.round(iconSize / scale); diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index 728b7ecb34708..bc1fe6af518a2 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -6,7 +6,7 @@ import { type NativeSyntheticEvent, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { NewThreadButton } from "../../components/NewThreadButton"; +import { ScrollComposeButton } from "../../components/ScrollComposeButton.android"; import { androidHomeFabPosition, type AndroidHomeFabLayout as SharedAndroidHomeFabLayout, @@ -16,7 +16,8 @@ import { FabScrollContext } from "./FabScrollContext"; import { updateFabScroll } from "./fab-scroll"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { WorkspaceTabsContext, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -import { FLOATING_TABS } from "../workspace/floating-workspace-tabs"; +import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; +import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; import { useAppearancePreferences, useAndroidFloatingTabs, @@ -30,9 +31,17 @@ export function AndroidHomeFabLayout(props: ComponentProps(null); const { state } = useWorkspaceState(); const [expanded, setExpanded] = useState(true); - const { fabSize } = useAndroidControlSizing(); + const { scale, fabSize } = useAndroidControlSizing(); + const enabledTabs = useEnabledNavigationTabs(); + const barWidth = + layoutWidth === null + ? undefined + : layoutWidth - (Math.max(insets.left, FLOATING_TABS.edge) - FLOATING_TABS.edge); + const size = floatingTabsEnabled + ? floatingTabsMetrics(enabledTabs.length + 1, scale, fabSize, barWidth).height + : fabSize; const workspace = useWorkspaceTabs(); - const bottomClearance = insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + fabSize; + const bottomClearance = insets.bottom + FLOATING_TABS.bottom + FLOATING_TABS.edge + size; const context = floatingTabsEnabled && workspace ? { ...workspace, bottomClearance } : workspace; const scrollState = useRef({ anchor: 0, expanded: true }); const onScroll = (event: NativeSyntheticEvent) => { @@ -57,9 +66,9 @@ export function AndroidHomeFabLayout(props: ComponentProps { - const metrics = floatingTabsMetrics(4, scale, fabSize, width, true); - expect(metrics.pill + 2 * 16 + 12 + fabSize).toBe(width); - expect(metrics.height).toBe(fabSize); + (width, scale, fabSize, count, height) => { + const metrics = floatingTabsMetrics(count, scale, fabSize, width, true); + expect(metrics.pill + 2 * 16 + 12 + metrics.height).toBeCloseTo(width); + expect(metrics.height).toBeCloseTo(height); + expect(metrics.pill / count).toBeGreaterThanOrEqual(metrics.height - 0.001); }, ); diff --git a/apps/mobile/src/features/workspace/floating-workspace-tabs.ts b/apps/mobile/src/features/workspace/floating-workspace-tabs.ts index 2957e7c44f14a..6612dcace29f0 100644 --- a/apps/mobile/src/features/workspace/floating-workspace-tabs.ts +++ b/apps/mobile/src/features/workspace/floating-workspace-tabs.ts @@ -9,12 +9,18 @@ export function floatingTabsMetrics( fillWidth = false, ) { const unit = Math.max(1, scale); - const beside = 2 * FLOATING_TABS.edge + FLOATING_TABS.gap + fabSize; + const spacing = 2 * FLOATING_TABS.edge + FLOATING_TABS.gap; + // Share width evenly when space is tight, retaining Android's 48dp touch target. + const height = + paneWidth === undefined + ? fabSize + : Math.max(48, Math.min(fabSize, (paneWidth - spacing) / (count + 1))); + const beside = spacing + height; const room = paneWidth === undefined ? count * 60 * unit : paneWidth - beside; return { // Reserve the compose slot even while its button is hidden. pill: Math.max(0, fillWidth ? room : Math.min(count * 72 * unit, room)), - height: fabSize, + height, row: beside + count * 60 * unit, }; } From a2d2d2c14ce2c073451a1112d5b47368782071b7 Mon Sep 17 00:00:00 2001 From: none23 Date: Mon, 2 Nov 2026 13:27:59 +0400 Subject: [PATCH 4/4] fix(mobile): account for left insets in split navigation --- .../features/workspace/AndroidWorkspaceNavigation.android.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx index 247f7e00a5b3a..53cfcc5a33eac 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx @@ -97,8 +97,8 @@ export function AndroidWorkspaceNavigation(props: { ); // FloatingWorkspaceTabs starts the bar at the left inset, so a cutout narrows the room. const barWidth = - props.paneWidth ?? - window.width - (Math.max(insets.left, FLOATING_TABS.edge) - FLOATING_TABS.edge); + (props.paneWidth ?? window.width) - + (Math.max(insets.left, FLOATING_TABS.edge) - FLOATING_TABS.edge); const metrics = floatingTabsMetrics(tabs.length, scale, fabSize, barWidth, props.fillWidth); if (!rail && keyboardVisible) return null;