diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx index 70a02932c606..bcb777e46c95 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx @@ -59,6 +59,7 @@ function WorkspaceRootContent() { const nativeMetrics = useNativeLayoutMetrics(); const systemTabBar = useSystemWorkspaceTabBar(); const sidebarTabs = useSidebarWorkspaceTabs(); + const sidebarPicker = sidebarTabs === "picker"; const sidebarNavigation = primaryColumn !== null && (layout.usesSplitView || @@ -98,8 +99,8 @@ function WorkspaceRootContent() { return ( {Platform.OS === "ios" ? ( )} - {sidebarTabs ? : null} - + {sidebarPicker ? : null} + {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 00e23196ef40..33c3fab8ec9e 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx @@ -11,6 +11,12 @@ 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"; +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"); export function WorkspaceToolbar(props: { readonly onNewTask: () => void; @@ -19,8 +25,12 @@ export function WorkspaceToolbar(props: { const workspace = useWorkspaceTabs()!; const navigation = useNavigation(); const metrics = useNativeLayoutMetrics(); + const sidebarTabs = useSidebarWorkspaceTabs(); + const selectTab = useSelectWorkspaceTab(); + const selectedTint = useMobileNavigationTheme().colors.primary; const nativeToolbarCompose = - Platform.OS === "ios" && (Platform.isPad || (metrics?.verticalBarEdge ?? "none") !== "none"); + Platform.OS === "ios" && + (Platform.isPad || sidebarTabs !== null || (metrics?.verticalBarEdge ?? "none") !== "none"); const { isTabActive } = workspace; const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true; const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); @@ -122,6 +132,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 427bf0a15697..d08f3095333b 100644 --- a/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts +++ b/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts @@ -1,12 +1,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(); - return Platform.OS === "ios" && Platform.isPad && layout.usesSplitView; + const metrics = useNativeLayoutMetrics(); + 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; } export function useSystemWorkspaceTabBar() { const sidebarTabs = useSidebarWorkspaceTabs(); - return Platform.OS === "ios" && !sidebarTabs; + return Platform.OS === "ios" && sidebarTabs === null; } diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 788bb709c284..49592afc6236 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -91,9 +91,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);