From 4066183baa896b00138633cbf3bfe00e7fc588e5 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 15 Oct 2026 20:24:41 -0700 Subject: [PATCH 1/2] fix(ios): show a sidebar toggle on the unfolded iPhone Duo in portrait Unfolded in portrait, the Duo's inner display reports a hinge but no side bar or fold division, so deriveLayout skipped the Duo rule and applied the iPad width floor (720pt) to its 669pt window. The layout went compact while UIKit kept the split expanded: the thread filled the screen with no way back to the list except an edge swipe. Treat any hinged display as Duo geometry, so unfolded portrait takes the split path iPad uses: the list beside the thread, the sidebar toggle in the thread header, and the Threads/Pull requests picker in the sidebar instead of the phone tab bar. Landscape keeps its side rail and the folded screen stays compact. --- .../workspace/WorkspaceToolbar.ios.tsx | 5 ++- .../workspace/useSystemWorkspaceTabBar.ts | 10 ++++- apps/mobile/src/lib/layout.ts | 5 ++- apps/mobile/src/lib/reserved-regions.test.ts | 37 +++++++++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx index 8ec0c8889afd..af1a1c9b721b 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx @@ -11,6 +11,7 @@ import { NativeWorkspaceTabActivityContext } from "../../native/v5-workspace-con import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; +import { useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar"; /** Root actions stay in the header; UIKit owns the bottom compose button. */ export function WorkspaceToolbar(props: { @@ -20,8 +21,10 @@ export function WorkspaceToolbar(props: { const workspace = useWorkspaceTabs()!; const navigation = useNavigation(); const metrics = useNativeLayoutMetrics(); + const sidebarTabs = useSidebarWorkspaceTabs(); const nativeToolbarCompose = - Platform.OS === "ios" && (Platform.isPad || (metrics?.verticalBarEdge ?? "none") !== "none"); + Platform.OS === "ios" && + (Platform.isPad || sidebarTabs || (metrics?.verticalBarEdge ?? "none") !== "none"); const { isTabActive } = workspace; const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true; const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); diff --git a/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts b/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts index e2d5a0c8e534..611c2adddfd7 100644 --- a/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts +++ b/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts @@ -1,9 +1,17 @@ import { Platform } from "react-native"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; export function useSidebarWorkspaceTabs() { const { layout } = useAdaptiveWorkspaceLayout(); - return Platform.OS === "ios" && Platform.isPad && layout.usesSplitView; + const metrics = useNativeLayoutMetrics(); + // The Duo's side rail carries the tabs; without it (unfolded in portrait), + // the split follows iPad. + return ( + Platform.OS === "ios" && + layout.usesSplitView && + (Platform.isPad || (metrics?.verticalBarEdge ?? "none") === "none") + ); } /** Compact iOS windows use UIKit tabs; wide iPad windows use the sidebar picker. */ diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index ac4cde7789b0..e479efc44966 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -99,9 +99,12 @@ export function deriveLayout(input: { const usableWidth = width - (metrics?.safeArea.left ?? 0) - (metrics?.safeArea.right ?? 0); // UIKit's size class supports the shorter Duo inner display. Geometry remains // a floor so a narrow multitasking window cannot squeeze both columns. + // Unfolded in portrait, the inner display reports neither a side bar nor a + // fold, only its hinge. const hasDuoGeometry = metrics && - (metrics.verticalBarEdge !== "none" || + (metrics.hasHinge === true || + metrics.verticalBarEdge !== "none" || metrics.reservedRegions.some((region) => region.kind === "division")); const wideEnoughForSplit = hasDuoGeometry ? metrics.horizontalSizeClass === "regular" && diff --git a/apps/mobile/src/lib/reserved-regions.test.ts b/apps/mobile/src/lib/reserved-regions.test.ts index e742169bd94a..e1da0fa04842 100644 --- a/apps/mobile/src/lib/reserved-regions.test.ts +++ b/apps/mobile/src/lib/reserved-regions.test.ts @@ -108,6 +108,43 @@ describe("Duo workspace sizing", () => { ).toBe(false); }); + it("shows both columns on the unfolded inner display in portrait", () => { + // Reported by the iOS 27.1 Duo simulator: no side bar or fold, only the hinge. + const portrait: NativeLayoutMetrics = { + width: 669, + height: 951, + hasHinge: true, + horizontalSizeClass: "regular", + verticalBarEdge: "none", + safeArea: { top: 82, bottom: 34, left: 0, right: 0 }, + reservedRegions: [{ kind: "occlusion", x: 535, y: 0, width: 134, height: 82 }], + }; + expect(deriveLayout({ ...portrait, nativeMetrics: portrait }).usesSplitView).toBe(true); + // Without the hinge, the same window is an ordinary narrow iPad-style window. + const withoutHinge = { ...portrait, hasHinge: false }; + expect(deriveLayout({ ...withoutHinge, nativeMetrics: withoutHinge }).usesSplitView).toBe( + false, + ); + }); + + it("keeps the folded front screen compact in both orientations", () => { + for (const [width, height] of [ + [466, 678], + [678, 466], + ]) { + const folded: NativeLayoutMetrics = { + width: width!, + height: height!, + hasHinge: true, + horizontalSizeClass: "compact", + verticalBarEdge: "right", + safeArea: { top: 0, bottom: 34, left: 0, right: 84 }, + reservedRegions: [], + }; + expect(deriveLayout({ ...folded, nativeMetrics: folded }).usesSplitView).toBe(false); + } + }); + it("requires enough safe width for both panes in a multitasking window", () => { const metrics = { ...innerDisplay, width: 660 }; expect(deriveLayout({ ...metrics, nativeMetrics: metrics }).usesSplitView).toBe(false); From c8bbdcc78e226aa59d5602e452729fc8f7e1341f Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 15 Oct 2026 20:44:23 -0700 Subject: [PATCH 2/2] fix(ios): keep phone-style tabs in the unfolded Duo's thread list The previous commit gave the unfolded portrait Duo iPad's segmented picker. Keep tab buttons instead: Threads and Pull requests sit at the leading edge of the thread list's bottom toolbar, with compose at the trailing edge, so they hide with the list. UIKit's own tab bar cannot be leading-aligned, so it stays hidden in this posture. iPad keeps its picker, which now shares the tab selection logic. --- .../workspace/WorkspaceRootScreen.tsx | 7 ++--- .../workspace/WorkspaceSidebarTabs.ios.tsx | 13 +++------ .../workspace/WorkspaceToolbar.ios.tsx | 27 ++++++++++++++++++- .../workspace/useSelectWorkspaceTab.ts | 22 +++++++++++++++ .../workspace/useSystemWorkspaceTabBar.ts | 19 +++++++------ 5 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 apps/mobile/src/features/workspace/useSelectWorkspaceTab.ts diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx index d86abc2a36b2..2e31758b12ac 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx @@ -60,6 +60,7 @@ function WorkspaceRootContent() { const nativeMetrics = useNativeLayoutMetrics(); const systemTabBar = useSystemWorkspaceTabBar(); const sidebarTabs = useSidebarWorkspaceTabs(); + const sidebarPicker = sidebarTabs === "picker"; const sidebarNavigation = primaryColumn !== null && (layout.usesSplitView || @@ -100,7 +101,7 @@ function WorkspaceRootContent() { {Platform.OS === "ios" ? ( )} - {sidebarTabs ? : null} + {sidebarPicker ? : null} {/* The pinned picker already clears the navigation bar; lists must not inset it again. */} - + {state.selectedTab === "threads" ? : } { - if (tab === workspace.state.selectedTab) return; - Keyboard.dismiss(); - if (navigation.getState()?.index) navigation.dispatch(StackActions.popToTop()); - workspace.dispatch({ type: "select", tab }); - }} + onSelect={(tab: WorkspaceTab) => selectTab(tab)} /> ); diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx index af1a1c9b721b..64927bc5524f 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx @@ -12,6 +12,11 @@ import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar"; +import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; +import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; + +// Tabler's git-pull-request geometry, shared with the UIKit tab bar. +const PULL_REQUEST_ICON = require("../../../assets/icons/pull-request.png"); /** Root actions stay in the header; UIKit owns the bottom compose button. */ export function WorkspaceToolbar(props: { @@ -22,9 +27,11 @@ export function WorkspaceToolbar(props: { const navigation = useNavigation(); const metrics = useNativeLayoutMetrics(); const sidebarTabs = useSidebarWorkspaceTabs(); + const selectTab = useSelectWorkspaceTab(); + const selectedTint = useMobileNavigationTheme().colors.primary; const nativeToolbarCompose = Platform.OS === "ios" && - (Platform.isPad || sidebarTabs || (metrics?.verticalBarEdge ?? "none") !== "none"); + (Platform.isPad || sidebarTabs !== null || (metrics?.verticalBarEdge ?? "none") !== "none"); const { isTabActive } = workspace; const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true; const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); @@ -127,6 +134,24 @@ export function WorkspaceToolbar(props: { )} + {sidebarTabs === "toolbar" ? ( + selectTab("threads")} + /> + ) : null} + {sidebarTabs === "toolbar" ? ( + selectTab("pullRequests")} + /> + ) : null} {nativeToolbarCompose ? : null} {nativeToolbarCompose ? ( { + if (tab === selectedTab) return; + Keyboard.dismiss(); + if (navigation.getState()?.index) navigation.dispatch(StackActions.popToTop()); + dispatch({ type: "select", tab }); + }, + [dispatch, navigation, selectedTab], + ); +} diff --git a/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts b/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts index 611c2adddfd7..98b0227cec8f 100644 --- a/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts +++ b/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts @@ -2,20 +2,19 @@ import { Platform } from "react-native"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; -export function useSidebarWorkspaceTabs() { +/** Where an iOS split window shows the workspace tabs instead of UIKit's tab bar. */ +export function useSidebarWorkspaceTabs(): "picker" | "toolbar" | null { const { layout } = useAdaptiveWorkspaceLayout(); const metrics = useNativeLayoutMetrics(); - // The Duo's side rail carries the tabs; without it (unfolded in portrait), - // the split follows iPad. - return ( - Platform.OS === "ios" && - layout.usesSplitView && - (Platform.isPad || (metrics?.verticalBarEdge ?? "none") === "none") - ); + if (Platform.OS !== "ios" || !layout.usesSplitView) return null; + if (Platform.isPad) return "picker"; + // The Duo's side rail carries UIKit's tabs. Unfolded in portrait it has no + // rail, so the tabs sit in the thread list's own toolbar. + return (metrics?.verticalBarEdge ?? "none") === "none" ? "toolbar" : null; } -/** Compact iOS windows use UIKit tabs; wide iPad windows use the sidebar picker. */ +/** Compact iOS windows use UIKit tabs; split windows keep them in the sidebar. */ export function useSystemWorkspaceTabBar() { const sidebarTabs = useSidebarWorkspaceTabs(); - return Platform.OS === "ios" && !sidebarTabs; + return Platform.OS === "ios" && sidebarTabs === null; }