authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 16:02:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 16:02:19-07:00
logc85dc642681a944e644cbbbea7257d274822c21a
tree7493ad81cf8ee41c14bed8ab7eaf63430bf515ba
parenta0a0e94822a0c675fbe0792ef94ff218101aeb66
signature Signed by PGP key B5690EEEBB952194

feat(mobile): support Duo in the shared iOS app (#12648)


42 files changed, 808 insertions(+), 126 deletions(-)

apps/mobile/app.config.ts+5
......@@ -264,6 +264,11 @@ const config: ExpoConfig = {
264264 "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`],
265265 },
266266 infoPlist: {
267 UISupportedInterfaceOrientations: [
268 "UIInterfaceOrientationPortrait",
269 "UIInterfaceOrientationLandscapeLeft",
270 "UIInterfaceOrientationLandscapeRight",
271 ],
267272 NSAppTransportSecurity: {
268273 NSAllowsArbitraryLoads: true,
269274 },
apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz deleted
Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz and /dev/null differ
apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz created
Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz differ
apps/mobile/eas.json+15-3
......@@ -13,7 +13,10 @@
1313 },
1414 "channel": "development",
1515 "developmentClient": true,
16 "distribution": "internal"
16 "distribution": "internal",
17 "ios": {
18 "image": "macos-tahoe-26.6-xcode-27.1"
19 }
1720 },
1821 "preview": {
1922 "corepack": true,
......@@ -23,7 +26,10 @@
2326 },
2427 "channel": "preview",
2528 "environment": "preview",
26 "distribution": "internal"
29 "distribution": "internal",
30 "ios": {
31 "image": "macos-tahoe-26.6-xcode-27.1"
32 }
2733 },
2834 "preview:dev": {
2935 "corepack": true,
......@@ -38,6 +44,9 @@
3844 "distribution": "internal",
3945 "android": {
4046 "buildType": "apk"
47 },
48 "ios": {
49 "image": "macos-tahoe-26.6-xcode-27.1"
4150 }
4251 },
4352 "v2-preview": {
......@@ -56,7 +65,10 @@
5665 },
5766 "channel": "production",
5867 "environment": "production",
59 "autoIncrement": true
68 "autoIncrement": true,
69 "ios": {
70 "image": "macos-tahoe-26.6-xcode-27.1"
71 }
6072 }
6173 },
6274 "submit": {
apps/mobile/modules/t3-native-controls/expo-module.config.json+2-1
......@@ -1,7 +1,8 @@
11{
22 "platforms": ["apple", "android"],
33 "apple": {
4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"]
4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"],
5 "appDelegateSubscribers": ["T3WorkspaceOrientationSubscriber"]
56 },
67 "android": {
78 "modules": [
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+6
......@@ -17,9 +17,13 @@ public final class T3KeyboardCommandsModule: Module {
1717public final class T3KeyboardCommandsView: ExpoView {
1818 let onCommand = EventDispatcher()
1919 private var enabledCommands = Set<String>()
20 private let shortcutInputView = UIView(frame: .zero)
2021
2122 public override var canBecomeFirstResponder: Bool { true }
2223
24 // This responder handles hardware shortcuts, so reclaiming it must not show a software keyboard.
25 public override var inputView: UIView? { shortcutInputView }
26
2327 public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
2428 if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss),
2529 let input = window?.t3FirstResponder as? UITextInput,
......@@ -89,6 +93,8 @@ public final class T3KeyboardCommandsView: ExpoView {
8993
9094 public required init(appContext: AppContext? = nil) {
9195 super.init(appContext: appContext)
96 inputAssistantItem.leadingBarButtonGroups = []
97 inputAssistantItem.trailingBarButtonGroups = []
9298 NotificationCenter.default.addObserver(
9399 self,
94100 selector: #selector(reclaimFirstResponderIfAvailable),
apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift+49-2
......@@ -1,10 +1,12 @@
11import ExpoModulesCore
22import UIKit
33
4/// Reports the body geometry of a native navigation column.
4/// Reports geometry in the observing view's coordinate space, including system
5/// reservations that safe-area rectangles cannot represent, such as the fold.
56final class T3LayoutMetricsView: ExpoView {
67 let onMetricsChange = EventDispatcher()
78 private var lastMetrics: NSDictionary?
9 private var hasHinge: Bool? = false
810
911 required init(appContext: AppContext? = nil) {
1012 super.init(appContext: appContext)
......@@ -12,6 +14,22 @@ final class T3LayoutMetricsView: ExpoView {
1214 registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in
1315 view.publishMetrics()
1416 }
17 #if compiler(>=6.4)
18 if #available(iOS 27.1, *) {
19 hasHinge = nil
20 addInteraction(UIHingeInteraction { [weak self] _, update in
21 guard let self, self.window != nil else { return }
22 // Capability survives temporary detachment and does not follow the hinge angle.
23 let hasHinge = self.hasHinge == true || update.hinge != nil
24 guard self.hasHinge != hasHinge else { return }
25 self.hasHinge = hasHinge
26 self.publishMetrics()
27 })
28 registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (view: T3LayoutMetricsView, _: UITraitCollection) in
29 view.publishMetrics()
30 }
31 }
32 #endif
1533 }
1634
1735 override func didMoveToWindow() {
......@@ -31,14 +49,43 @@ final class T3LayoutMetricsView: ExpoView {
3149 }
3250
3351 private func publishMetrics() {
34 guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
52 guard let window, bounds.width > 0, bounds.height > 0, let hasHinge else { return }
53 if hasHinge {
54 T3WorkspaceOrientationSubscriber.allowHingeOrientations(in: window)
55 }
56 var verticalBarEdge = "none"
57 var regions: [[String: Any]] = []
58 #if compiler(>=6.4)
59 if #available(iOS 27.1, *) {
60 let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft
61 switch traitCollection.verticalBarEdge {
62 case .leading: verticalBarEdge = rtl ? "right" : "left"
63 case .trailing: verticalBarEdge = rtl ? "left" : "right"
64 default: break
65 }
66 for kind in [UIView.ReservedRegion.Kind.division, .occlusion] {
67 for region in reservedRegions(kind: kind) where region.isActive {
68 let frame = region.frame.intersection(bounds)
69 guard !frame.isNull, !frame.isEmpty else { continue }
70 regions.append([
71 "kind": kind == .division ? "division" : "occlusion",
72 "x": frame.minX, "y": frame.minY,
73 "width": frame.width, "height": frame.height,
74 ])
75 }
76 }
77 }
78 #endif
3579 let metrics: [String: Any] = [
3680 "width": bounds.width, "height": bounds.height,
81 "hasHinge": hasHinge,
3782 "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact",
83 "verticalBarEdge": verticalBarEdge,
3884 "safeArea": [
3985 "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom,
4086 "left": safeAreaInsets.left, "right": safeAreaInsets.right,
4187 ],
88 "reservedRegions": regions,
4289 ]
4390 let next = metrics as NSDictionary
4491 guard lastMetrics?.isEqual(next) != true else { return }
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+3-1
......@@ -13,7 +13,9 @@ public final class T3NativeControlsModule: Module {
1313 public func definition() -> ModuleDefinition {
1414 Constants {
1515 if #available(iOS 26.0, *) {
16 return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil]
16 return [
17 "supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil,
18 ]
1719 }
1820 return ["supportsWorkspaceColumns": false]
1921 }
apps/mobile/modules/t3-native-controls/ios/T3WorkspaceOrientationSubscriber.swift created+22
......@@ -0,0 +1,22 @@
1import ExpoModulesCore
2import UIKit
3
4/// Keep ordinary phones portrait-only while allowing a hinge-backed scene to rotate.
5public final class T3WorkspaceOrientationSubscriber: ExpoAppDelegateSubscriber {
6 private static let hingeWindows = NSHashTable<UIWindow>.weakObjects()
7
8 static func allowHingeOrientations(in window: UIWindow) {
9 guard !hingeWindows.contains(window) else { return }
10 hingeWindows.add(window)
11 window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
12 }
13
14 public func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
15 if UIDevice.current.userInterfaceIdiom == .pad {
16 // Keep iPad orientation policy, including portrait-only showcase captures.
17 let orientations = Bundle.main.object(forInfoDictionaryKey: "UISupportedInterfaceOrientations~ipad") as? [String] ?? []
18 return orientations == ["UIInterfaceOrientationPortrait"] ? .portrait : .all
19 }
20 return window.map { Self.hingeWindows.contains($0) } == true ? .allButUpsideDown : .portrait
21 }
22}
apps/mobile/package.json+1-1
......@@ -127,7 +127,7 @@
127127 "react-native-nitro-modules": "0.35.9",
128128 "react-native-reanimated": "4.7.0",
129129 "react-native-safe-area-context": "~5.9.1",
130 "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.7.tgz",
130 "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.8.tgz",
131131 "react-native-shiki-engine": "^0.3.12",
132132 "react-native-svg": "15.15.5",
133133 "react-native-webview": "14.0.1",
apps/mobile/src/App.tsx+8-5
......@@ -27,6 +27,7 @@ import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
2727import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
2828import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider";
2929import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
30import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics";
3031
3132import "../global.css";
3233
......@@ -91,11 +92,13 @@ function AppContent() {
9192 header (glass buttons, title, materials) is forced light even when
9293 the system is in dark mode. */}
9394 <GlobalVoiceInputControl>
94 <IncomingShareProvider>
95 <Navigation linking={appLinking} theme={navigationTheme} />
96 </IncomingShareProvider>
97 <ConfirmDialogHost />
98 <ThreadArrangementHost />
95 <NativeLayoutMetricsProvider>
96 <IncomingShareProvider>
97 <Navigation linking={appLinking} theme={navigationTheme} />
98 </IncomingShareProvider>
99 <ConfirmDialogHost />
100 <ThreadArrangementHost />
101 </NativeLayoutMetricsProvider>
99102 </GlobalVoiceInputControl>
100103 {/* Anchored-menu overlays render here — in-window, so the
101104 keyboard stays up while a dropdown is open. */}
apps/mobile/src/Stack.tsx+9-1
......@@ -128,6 +128,7 @@ import {
128128} from "./features/sharing/incoming-share-presentation";
129129import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
130130import { deriveLayout } from "./lib/layout";
131import { useNativeLayoutMetrics } from "./native/native-layout-metrics";
131132import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
132133import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";
133134import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
......@@ -192,6 +193,9 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {
192193 presentation: "fullScreenModal",
193194};
194195
196// A navigator container leaves horizontal safe-area handling to its leaf screens.
197const NESTED_NAVIGATOR_OPTIONS = { nativeContentInsetHorizontally: false, headerShown: false };
198
195199const SettingsContentStack = createV5SheetStackNavigator({
196200 initialRouteName: "Settings",
197201 screenOptions: {
......@@ -410,6 +414,7 @@ const SettingsSheetStack = createV5SheetStackNavigator({
410414 screens: {
411415 SettingsContent: createNativeStackScreen({
412416 screen: SettingsContentStack,
417 options: NESTED_NAVIGATOR_OPTIONS,
413418 linking: "",
414419 layout: ({ children }) => (
415420 <SettingsEnvironmentFilterProvider>
......@@ -845,6 +850,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
845850 screen: SettingsSheetStack,
846851 linking: "settings",
847852 options: {
853 ...NESTED_NAVIGATOR_OPTIONS,
848854 gestureEnabled: true,
849855 headerShown: false,
850856 },
......@@ -911,6 +917,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
911917 </GuardedScreenLayout>
912918 ),
913919 options: {
920 ...NESTED_NAVIGATOR_OPTIONS,
914921 gestureEnabled: true,
915922 headerShown: false,
916923 },
......@@ -951,8 +958,9 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName:
951958
952959export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {
953960 const { width, height } = useWindowDimensions();
961 const nativeMetrics = useNativeLayoutMetrics();
954962 const usesWorkspaceFlowScreens =
955 Platform.OS === "android" || deriveLayout({ width, height }).usesSplitView;
963 Platform.OS === "android" || deriveLayout({ width, height, nativeMetrics }).usesSplitView;
956964
957965 return (
958966 <Navigator
apps/mobile/src/components/ScreenHeader.tsx+12-4
......@@ -1,9 +1,10 @@
11import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
22import { useId } from "react";
3import { ActivityIndicator, View } from "react-native";
3import { ActivityIndicator, Platform, View } from "react-native";
44import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";
55import { ScreenHeaderButton } from "./ScreenHeaderButton";
66import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader";
7import { useNativeWorkspaceColumnsSupported } from "../native/NativeWorkspaceColumns";
78import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout";
89import {
910 createNativeMailSearchToolbarItem,
......@@ -12,6 +13,7 @@ import {
1213import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1314import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types";
1415import type { AppSymbolName } from "./AppSymbol";
16import { useNativeMailSearchToolbar } from "../native/use-native-mail-search-toolbar";
1517
1618function iosIcon(icon: AppSymbolName) {
1719 return typeof icon === "string" ? icon : icon.ios;
......@@ -33,15 +35,17 @@ function mailMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>): MailMenu["it
3335}
3436
3537export function ScreenHeader(props: ScreenHeaderProps) {
38 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
3639 const headerId = useId();
3740 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
3841 const { themeVariables } = useAppearancePreferences();
42 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
3943 const { search, menus } = props;
4044 const menu = menus?.[0];
4145 const compactSearch =
4246 search !== undefined &&
4347 (search.compactToolbar ?? !layout.usesSplitView) &&
44 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
48 usesNativeMailSearchToolbar;
4549 const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []);
4650 const refresh = search?.refreshInToolbar ? search.onRefresh : undefined;
4751 return (
......@@ -88,7 +92,7 @@ export function ScreenHeader(props: ScreenHeaderProps) {
8892 : undefined),
8993 }),
9094 ]
91 : undefined,
95 : () => [],
9296 headerSearchBarOptions: compactSearch
9397 ? undefined
9498 : {
......@@ -116,8 +120,12 @@ export function ScreenHeader(props: ScreenHeaderProps) {
116120 onPress={props.backInSplitView.onPress ?? props.onBack}
117121 />
118122 ) : null}
119 {props.sidebar !== false ? (
123 {props.sidebar !== false &&
124 (!usesNativeWorkspaceColumns ||
125 (Platform.OS === "ios" && Platform.isPad) ||
126 !panes.primarySidebarVisible) ? (
120127 <ScreenHeaderButton
128 axisBehavior={usesNativeWorkspaceColumns ? "horizontalOnly" : undefined}
121129 accessibilityLabel={
122130 panes.primarySidebarVisible
123131 ? `Maximize ${props.title.toLowerCase()}`
apps/mobile/src/features/home/HomeHeader.tsx+34-11
......@@ -1,25 +1,43 @@
11import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { use, useCallback, useRef } from "react";
2import { use, useCallback, useEffect, useRef } from "react";
3import { useNavigation, type ParamListBase } from "@react-navigation/native";
4import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
35import { Platform } from "react-native";
46import type { SearchBarCommands } from "react-native-screens";
57import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
68import { useUniwindTheme } from "../../lib/useUniwindTheme";
79import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
810import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
9import {
10 createNativeMailSearchToolbarItem,
11 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
12} from "../layout/native-mail-search-toolbar";
11import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
12import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
1313import { buildHomeListFilterMenu } from "./home-list-filter-menu";
1414import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items";
1515import type { HomeHeaderProps } from "./HomeHeader.types";
16import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
1617
1718export type { HomeHeaderEnvironment } from "./HomeHeader.types";
1819
1920export function HomeHeader(props: HomeHeaderProps) {
21 const navigation = useNavigation<NativeStackNavigationProp<ParamListBase>>();
22 const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
23 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
2024 const primaryColumn = use(NativePrimaryColumnContext);
21 const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null;
25 const sidebarHeader =
26 Platform.OS === "ios" &&
27 primaryColumn !== null &&
28 (Platform.isPad || !usesNativeMailSearchToolbar);
2229 const searchBarRef = useRef<SearchBarCommands>(null);
30 const focusAfterReveal = useRef(false);
31 useEffect(
32 () =>
33 navigation.addListener("transitionEnd", (event) => {
34 if (focusAfterReveal.current && !event.data.closing) {
35 focusAfterReveal.current = false;
36 searchBarRef.current?.focus();
37 }
38 }),
39 [navigation],
40 );
2341 const iconColor = useUniwindTheme()["--color-icon"];
2442 // The list uses a fixed creation order and ignores sort/group options, so
2543 // the filter menu only carries the filters and the "customized" icon state
......@@ -27,9 +45,14 @@ export function HomeHeader(props: HomeHeaderProps) {
2745 const hasCustomListOptions =
2846 props.selectedEnvironmentId !== null || props.selectedProjectKey !== null;
2947 const focusSearch = useCallback(() => {
48 if (primaryColumn && !panes.primarySidebarVisible) {
49 focusAfterReveal.current = true;
50 togglePrimarySidebar();
51 return true;
52 }
3053 searchBarRef.current?.focus();
3154 return searchBarRef.current !== null;
32 }, []);
55 }, [primaryColumn, panes.primarySidebarVisible, togglePrimarySidebar]);
3356 useHardwareKeyboardCommand("focusSearch", focusSearch);
3457 const filterMenu = buildHomeListFilterMenu(props);
3558
......@@ -42,7 +65,7 @@ export function HomeHeader(props: HomeHeaderProps) {
4265 // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
4366 headerTintColor: iconColor,
4467 unstable_headerRightItems: () =>
45 iPadSidebar
68 sidebarHeader
4669 ? createSidebarHeaderItems({
4770 filterIcon: hasCustomListOptions
4871 ? "line.3.horizontal.decrease.circle.fill"
......@@ -62,7 +85,7 @@ export function HomeHeader(props: HomeHeaderProps) {
6285 ],
6386 // The keys below are set per-branch (not `undefined`) so a later
6487 // reapply cannot clobber options owned by NativeHeaderToolbar.
65 ...(iPadSidebar
88 ...(sidebarHeader
6689 ? {
6790 headerSearchBarOptions: {
6891 ref: searchBarRef,
......@@ -78,7 +101,7 @@ export function HomeHeader(props: HomeHeaderProps) {
78101 },
79102 unstable_headerToolbarItems: () => [],
80103 }
81 : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
104 : usesNativeMailSearchToolbar
82105 ? {
83106 headerSearchBarOptions: {
84107 ref: searchBarRef,
......@@ -121,7 +144,7 @@ export function HomeHeader(props: HomeHeaderProps) {
121144 }}
122145 />
123146
124 {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (
147 {sidebarHeader || usesNativeMailSearchToolbar ? null : (
125148 <NativeHeaderToolbar placement="bottom">
126149 <NativeHeaderToolbar.Menu
127150 accessibilityLabel="Filter threads"
apps/mobile/src/features/home/HomeScreen.tsx+14-2
......@@ -36,8 +36,12 @@ import type { SavedRemoteConnection } from "../../lib/connection";
3636import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
3737import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
3838import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects";
39import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
39import {
40 useNativeColumnLayoutMetrics,
41 useNativeLayoutMetrics,
42} from "../../native/native-layout-metrics";
4043import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
44import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
4145import { useThreadSearch } from "../../state/queries";
4246import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
4347import { usePendingThreadOrder } from "../../state/thread-order";
......@@ -264,6 +268,7 @@ function renderHomeScrollView(props: ScrollViewProps) {
264268/* ─── Main screen ────────────────────────────────────────────────────── */
265269
266270export function HomeScreen(props: HomeScreenProps) {
271 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
267272 const primaryColumn = use(NativePrimaryColumnContext);
268273 const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen";
269274 const containerClassName = cn(
......@@ -277,6 +282,10 @@ export function HomeScreen(props: HomeScreenProps) {
277282 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
278283 const insets = useSafeAreaInsets();
279284 const { fabClearance } = useAndroidControlSizing();
285 const screenMetrics = useNativeLayoutMetrics();
286 const contentSideInsets = usesNativeWorkspaceColumns
287 ? (columnMetrics ?? screenMetrics)?.safeArea
288 : undefined;
280289 // UIKit's column safe area already includes its bottom toolbar.
281290 const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24);
282291 const searchEnvironmentIds = useMemo(
......@@ -908,6 +917,8 @@ export function HomeScreen(props: HomeScreenProps) {
908917 style={{
909918 paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24),
910919 paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0,
920 paddingLeft: 32 + (contentSideInsets?.left ?? 0),
921 paddingRight: 32 + (contentSideInsets?.right ?? 0),
911922 }}
912923 >
913924 <View className="w-full max-w-[430px]">
......@@ -1038,7 +1049,8 @@ export function HomeScreen(props: HomeScreenProps) {
10381049 {...scrollGateHandlers}
10391050 scrollEventThrottle={16}
10401051 contentContainerStyle={{
1041 paddingHorizontal: primaryColumn ? 8 : 0,
1052 paddingLeft: (contentSideInsets?.left ?? 0) + (primaryColumn ? 8 : 0),
1053 paddingRight: (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0),
10421054 paddingBottom:
10431055 Platform.OS === "ios"
10441056 ? iosBottomClearance
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+23-6
......@@ -66,6 +66,8 @@ import {
6666 NativeWorkspaceInspectorContext,
6767} from "../../native/v5-workspace-context";
6868
69import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
70
6971interface AdaptiveWorkspaceContextValue {
7072 readonly layout: Layout;
7173 readonly panes: WorkspacePaneLayout;
......@@ -239,7 +241,9 @@ function AdaptiveWorkspaceLayoutContent(
239241) {
240242 const projectGroupingMode = props.projectGroupingMode;
241243 const nativeWorkspace = use(NativeWorkspaceModeContext);
242 const { width, height } = useWindowDimensions();
244 const windowDimensions = useWindowDimensions();
245 const nativeMetrics = useNativeLayoutMetrics();
246 const { width, height } = nativeMetrics ?? windowDimensions;
243247 const pathname = props.pathname;
244248 const navigation = useNavigation();
245249 const activeRoleOwner = useRef<symbol | null>(null);
......@@ -256,7 +260,10 @@ function AdaptiveWorkspaceLayoutContent(
256260 const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState("");
257261 const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] =
258262 useState<WorkspaceAuxiliaryPaneRole | null>(null);
259 const baseLayout = useMemo(() => deriveLayout({ width, height }), [height, width]);
263 const baseLayout = useMemo(
264 () => deriveLayout({ width, height, nativeMetrics }),
265 [height, width, nativeMetrics],
266 );
260267 const layout = baseLayout;
261268 // In split layouts the sidebar IS the thread list — it renders on every
262269 // route, including Home (which shows an empty-detail pane instead of the
......@@ -269,7 +276,9 @@ function AdaptiveWorkspaceLayoutContent(
269276 viewportWidth: width,
270277 preferredWidth: fileInspectorPreferredWidth ?? undefined,
271278 reservedLeadingWidth:
272 shouldRenderPrimarySidebar && showPrimarySidebar ? (layout.listPaneWidth ?? 0) : 0,
279 shouldRenderPrimarySidebar && showPrimarySidebar
280 ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0)
281 : 0,
273282 }),
274283 [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width],
275284 );
......@@ -470,13 +479,21 @@ function AdaptiveWorkspaceLayoutContent(
470479 );
471480
472481 const renderedSidebarWidth = useSharedValue(
473 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0,
482 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0,
474483 );
475484 useEffect(() => {
476485 if (nativeWorkspace) return;
477 const targetWidth = panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0;
486 const targetWidth = panes.primarySidebarVisible
487 ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0)
488 : 0;
478489 renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
479 }, [nativeWorkspace, layout.listPaneWidth, panes.primarySidebarVisible, renderedSidebarWidth]);
490 }, [
491 nativeWorkspace,
492 layout.listPaneWidth,
493 layout.listPaneGap,
494 panes.primarySidebarVisible,
495 renderedSidebarWidth,
496 ]);
480497 const sidebarAnimatedStyle = useAnimatedStyle(() => ({
481498 opacity: Math.min(1, renderedSidebarWidth.value / 80),
482499 width: renderedSidebarWidth.value,
apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx+59-9
......@@ -20,7 +20,7 @@ import {
2020 type NativeStackTypeBag,
2121} from "@react-navigation/native-stack";
2222import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react";
23import { View } from "react-native";
23import { Platform, View } from "react-native";
2424import { Split, type SplitHostCommands } from "react-native-screens";
2525import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2626import { scopedThreadKey } from "../../lib/scopedEntities";
......@@ -33,8 +33,13 @@ import {
3333 V5CardStackView,
3434 V5StackView,
3535} from "../../native/createV5StackNavigator.ios";
36import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns";
36import {
37 useNativeWorkspaceColumnsReady,
38 useNativeWorkspaceColumnsSupported,
39} from "../../native/NativeWorkspaceColumns";
3740import { V5StackHeader } from "../../native/V5StackHeader.ios";
41import type { AppNativeStackNavigationOptions } from "../../native/StackHeader";
42import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
3843import {
3944 nativeWorkspacePopAction,
4045 projectWorkspaceStack,
......@@ -89,7 +94,12 @@ function ColumnScreen(props: {
8994 canGoBack={!props.primary}
9095 primary={props.primary}
9196 />
92 <ColumnContent primary={props.primary}>
97 <ColumnContent
98 primary={props.primary}
99 insetHorizontally={
100 (descriptor.options as AppNativeStackNavigationOptions).nativeContentInsetHorizontally
101 }
102 >
93103 <NativePrimaryColumnContext value={primaryColumn}>
94104 {descriptor.render()}
95105 </NativePrimaryColumnContext>
......@@ -107,6 +117,8 @@ function WorkspaceColumns(
107117 const inspector = use(NativeWorkspaceInspectorContext);
108118 const hostRef = useRef<SplitHostCommands>(null);
109119 const activeDetailKey = props.detail.at(-1)?.key;
120 const compactColumn = activeDetailKey ? "secondary" : "primary";
121 const shownCompactColumn = useRef(compactColumn);
110122 const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params;
111123 const selectedThreadKey =
112124 threadParams &&
......@@ -131,9 +143,12 @@ function WorkspaceColumns(
131143 useEffect(() => {
132144 // In compact size classes UIKit exposes one column. Selecting a thread
133145 // changes the visible column without rebuilding either navigation stack.
134 if (layout.usesSplitView) return;
135 hostRef.current?.show(activeDetailKey ? "secondary" : "primary");
136 }, [activeDetailKey, layout.usesSplitView]);
146 // The initial column is a native prop, so it is applied before the host
147 // attaches. Commands only handle later navigation changes.
148 if (layout.usesSplitView || shownCompactColumn.current === compactColumn) return;
149 shownCompactColumn.current = compactColumn;
150 hostRef.current?.show(compactColumn);
151 }, [compactColumn, layout.usesSplitView]);
137152
138153 const primary = props.descriptors[props.primary.key];
139154 if (!primary) return null;
......@@ -142,6 +157,7 @@ function WorkspaceColumns(
142157 ref={hostRef}
143158 testID="adaptive-workspace-layout"
144159 preferredSplitBehavior="tile"
160 topColumnForCollapsing={compactColumn}
145161 preferredDisplayMode={
146162 panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly"
147163 }
......@@ -153,7 +169,7 @@ function WorkspaceColumns(
153169 }}
154170 columnMetrics={{
155171 minimumPrimaryColumnWidth: 280,
156 maximumPrimaryColumnWidth: 380,
172 maximumPrimaryColumnWidth: Math.max(380, layout.listPaneWidth ?? 0),
157173 preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320,
158174 minimumSecondaryColumnWidth: 320,
159175 }}
......@@ -179,6 +195,37 @@ function WorkspaceColumns(
179195 options={{
180196 headerShown: true,
181197 title: "",
198 unstable_headerRightItems: () =>
199 Platform.OS === "ios" && !Platform.isPad && layout.usesSplitView
200 ? [
201 ...(!panes.primarySidebarVisible
202 ? [
203 {
204 type: "button" as const,
205 axisBehavior: "verticalPreferred" as const,
206 accessibilityLabel: "Search threads",
207 label: "Search threads",
208 icon: {
209 type: "sfSymbol" as const,
210 name: "magnifyingglass" as const,
211 },
212 onPress: () => {
213 dispatchHardwareKeyboardCommand("focusSearch");
214 },
215 },
216 ]
217 : []),
218 {
219 type: "button",
220 axisBehavior: "verticalPreferred",
221 accessibilityLabel: "New task",
222 label: "New task",
223 icon: { type: "sfSymbol", name: "square.and.pencil" },
224 onPress: () =>
225 props.navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
226 },
227 ]
228 : [],
182229 unstable_headerLeftItems: () => [
183230 {
184231 type: "button",
......@@ -293,6 +340,8 @@ function WorkspaceStackNavigator({
293340 UNSTABLE_router,
294341 ...rest
295342}: NativeStackNavigatorProps) {
343 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
344 const nativeWorkspaceColumnsReady = useNativeWorkspaceColumnsReady();
296345 const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<
297346 StackNavigationState<ParamListBase>,
298347 StackRouterOptions,
......@@ -310,9 +359,10 @@ function WorkspaceStackNavigator({
310359 screenLayout,
311360 UNSTABLE_router,
312361 });
313 const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView;
362 if (!nativeWorkspaceColumnsReady) return null;
363 const WorkspaceView = usesNativeWorkspaceColumns ? WorkspaceStackView : V5StackView;
314364 return (
315 <NativeWorkspaceModeContext value={NATIVE_WORKSPACE_COLUMNS_SUPPORTED}>
365 <NativeWorkspaceModeContext value={usesNativeWorkspaceColumns}>
316366 <NavigationContent>
317367 <WorkspaceView
318368 {...rest}
apps/mobile/src/features/layout/native-glass-header-items.ts+1
......@@ -4,6 +4,7 @@ type NativeGlassHeaderItem = {
44 readonly hidesSharedBackground?: boolean;
55 readonly sharesBackground?: boolean;
66 readonly variant?: "plain" | "done" | "prominent";
7 readonly axisBehavior?: "automatic" | "horizontalOnly" | "verticalPreferred";
78 readonly width?: number;
89};
910
apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx+2-2
......@@ -19,8 +19,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
1919import {
2020 createNativeMailSearchToolbarItem,
2121 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
22 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
2322} from "../layout/native-mail-search-toolbar";
23import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
2424
2525import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses";
2626
......@@ -72,7 +72,7 @@ export function SettingsOpenSourceLicensesRouteScreen() {
7272 const navigation = useNavigation();
7373 const insets = useSafeAreaInsets();
7474 const [query, setQuery] = useState("");
75 const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
75 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
7676 const manifest = useMobileThirdPartyLicenses();
7777 const entries = manifest?.entries ?? [];
7878 const filteredEntries = useMemo(
apps/mobile/src/features/settings/components/SettingsEnvironmentFilterHeader.tsx+2
......@@ -54,6 +54,7 @@ export function SettingsEnvironmentFilterHeader(props: {
5454 options={{
5555 unstable_headerRightItems: () => [
5656 withNativeGlassHeaderItem({
57 axisBehavior: "horizontalOnly",
5758 accessibilityLabel: "Filter settings environments and projects",
5859 icon: { name: filterIcon, type: "sfSymbol" },
5960 label: "",
......@@ -120,6 +121,7 @@ export function SettingsEnvironmentFilterHeader(props: {
120121 ...(closeSettings
121122 ? [
122123 withNativeGlassHeaderItem({
124 axisBehavior: "horizontalOnly",
123125 accessibilityLabel: "Close settings",
124126 icon: { name: "xmark", type: "sfSymbol" },
125127 identifier: "settings-close",
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+2-2
......@@ -37,8 +37,8 @@ import { vcsEnvironment } from "../../state/vcs";
3737import {
3838 createNativeMailSearchToolbarItem,
3939 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
40 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
4140} from "../layout/native-mail-search-toolbar";
41import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
4242import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
4343import { checkoutNewTaskBranch } from "./checkout-new-task-branch";
4444
......@@ -413,7 +413,7 @@ export function BranchPickerScreen(props: {
413413}) {
414414 const navigation = useNavigation();
415415 const insets = useSafeAreaInsets();
416 const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
416 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
417417 const selectedBranchName =
418418 props.selectedBranchName ??
419419 props.branches.find((branch) => branch.current)?.name ??
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+29-4
......@@ -14,6 +14,8 @@ import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp
1414import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
1515import type { LegendListRef } from "@legendapp/list/react-native";
1616import { HeaderHeightContext } from "@react-navigation/elements";
17import { NativeLayoutObserver } from "../../native/NativeLayoutObserver";
18import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/reserved-regions";
1719import type {
1820 EnvironmentId,
1921 MessageId,
......@@ -84,6 +86,8 @@ import Animated, {
8486} from "react-native-reanimated";
8587import { useSafeAreaInsets } from "react-native-safe-area-context";
8688import { useWorkspaceContentWidth } from "../layout/workspace-content-width";
89import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
90import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
8791import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
8892import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
8993import type { ComposerEditorHandle } from "../../components/ComposerEditor";
......@@ -317,6 +321,7 @@ const USER_INPUT_TOGGLE_TIMING = {
317321};
318322
319323export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
324 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
320325 const navigation = useNavigation();
321326 const { session: voiceInputSession } = useGlobalVoiceInput();
322327 const reportedModelSelection = useThreadReportedModelSelection({
......@@ -388,7 +393,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
388393 }
389394 }, []);
390395 const windowHeight = useWindowDimensions().height;
391 const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44;
396 const navigationHeaderHeight = useContext(HeaderHeightContext) ?? insets.top + 44;
397 const [screenMetrics, setScreenMetrics] = useState<NativeLayoutMetrics | null>(null);
398 const columnMetrics = useNativeColumnLayoutMetrics();
399 const nativeMetrics = usesNativeWorkspaceColumns ? columnMetrics : screenMetrics;
400 const controlInsets = deriveBottomControlInsets(nativeMetrics);
392401 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
393402 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
394403 const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null;
......@@ -826,8 +835,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
826835 // Assign both layouts explicitly so the dock always follows its current parent.
827836 const composerWidthStyle = useAnimatedStyle(() =>
828837 isSplitLayout && workspaceContentWidth !== null
829 ? { width: workspaceContentWidth.value }
830 : { width: "100%" },
838 ? {
839 width: Math.max(
840 0,
841 workspaceContentWidth.value - controlInsets.left - controlInsets.right,
842 ),
843 right: undefined,
844 }
845 : { width: undefined, right: controlInsets.right },
831846 );
832847 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
833848 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
......@@ -1083,6 +1098,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
10831098
10841099 return (
10851100 <View className="flex-1">
1101 {!usesNativeWorkspaceColumns ? <NativeLayoutObserver onChange={setScreenMetrics} /> : null}
10861102 {showContent ? (
10871103 <View
10881104 style={{ flex: 1 }}
......@@ -1140,6 +1156,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
11401156 }
11411157 contentMaxWidth={contentMaxWidth}
11421158 historyControls={props.historyControls}
1159 contentSideInsets={nativeMetrics?.safeArea}
11431160 layoutVariant={layoutVariant}
11441161 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
11451162 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
......@@ -1183,7 +1200,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
11831200 <Animated.View
11841201 layout={COMPOSER_LAYOUT_TRANSITION}
11851202 pointerEvents="box-none"
1186 style={[{ position: "absolute", bottom: 0, left: 0 }, composerWidthStyle]}
1203 style={[
1204 {
1205 position: "absolute",
1206 bottom: controlInsets.bottom,
1207 left: controlInsets.left,
1208 right: controlInsets.right,
1209 },
1210 composerWidthStyle,
1211 ]}
11871212 >
11881213 {/* No paddingTop here: the overlay's measured height becomes the
11891214 list's bottom inset, so any padding above the pill/composer
apps/mobile/src/features/threads/ThreadFeed.tsx+40-8
......@@ -57,6 +57,7 @@ import { videoMimeType } from "@t3tools/shared/video";
5757import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
5858import { HeaderHeightContext } from "@react-navigation/elements";
5959import { useFocusEffect, useNavigation } from "@react-navigation/native";
60import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
6061import {
6162 createContext,
6263 memo,
......@@ -297,6 +298,7 @@ export interface ThreadFeedProps {
297298 readonly contentBottomInset?: number;
298299 readonly historyControls?: ThreadFeedHistoryControls;
299300 readonly contentMaxWidth?: number;
301 readonly contentSideInsets?: { readonly left: number; readonly right: number };
300302 readonly layoutVariant?: LayoutVariant;
301303 readonly usesAutomaticContentInsets?: boolean;
302304 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
......@@ -1543,6 +1545,7 @@ function renderFeedEntry(
15431545 readonly reviewCommentColors: ReviewCommentColors;
15441546 readonly reviewCommentBubbleWidth: number;
15451547 readonly themeAppearance: "light" | "dark";
1548 readonly usesNativeWorkspaceColumns: boolean;
15461549 readonly userBubbleMaxWidth: number;
15471550 /** Width assistant markdown lays out in, so images can size their frame before layout. */
15481551 readonly markdownContentWidth: number;
......@@ -1730,11 +1733,19 @@ function renderFeedEntry(
17301733 className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"
17311734 style={{
17321735 backgroundColor: userBubbleColor,
1733 maxWidth: props.userBubbleMaxWidth,
1736 maxWidth:
1737 props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad
1738 ? "85%"
1739 : props.userBubbleMaxWidth,
17341740 ...(hasReviewCommentContext
17351741 ? { width: props.reviewCommentBubbleWidth }
17361742 : hasWideBlock
1737 ? { width: props.userBubbleMaxWidth }
1743 ? {
1744 width:
1745 props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad
1746 ? "85%"
1747 : props.userBubbleMaxWidth,
1748 }
17381749 : null),
17391750 }}
17401751 >
......@@ -2174,6 +2185,7 @@ function ThreadFeedPlaceholder(props: {
21742185}
21752186
21762187export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2188 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
21772189 const navigation = useNavigation();
21782190 const { themeAppearance } = useAppearancePreferences();
21792191 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
......@@ -2257,12 +2269,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22572269 setExpandedFile(null);
22582270 }, [props.environmentId, props.threadId, props.contentPresentation.kind]);
22592271 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;
2272 const contentLeftInset = props.contentSideInsets?.left ?? 0;
2273 const contentRightInset = props.contentSideInsets?.right ?? 0;
2274 const usableViewportWidth = Math.max(0, viewportWidth - contentLeftInset - contentRightInset);
22602275 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({
2261 viewportWidth,
2276 viewportWidth: usableViewportWidth,
22622277 maxContentWidth: props.contentMaxWidth ?? null,
22632278 minimumPadding: horizontalPadding,
22642279 });
2265 const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2);
2280 const contentWidth = Math.max(0, usableViewportWidth - contentHorizontalPadding * 2);
22662281 const userBubbleMaxWidth = contentWidth * 0.85;
22672282 const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2);
22682283 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
......@@ -2285,7 +2300,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22852300 // header-providing screen) and fall back to the standard iOS bar height.
22862301 const navigationHeaderHeight = useContext(HeaderHeightContext);
22872302 const anchorTopInset = usesNativeAutomaticInsets
2288 ? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT
2303 ? (navigationHeaderHeight ?? insets.top + IOS_NAV_BAR_HEIGHT)
22892304 : topContentInset;
22902305
22912306 const theme = useUniwindTheme();
......@@ -3030,6 +3045,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
30303045 reviewCommentColors,
30313046 reviewCommentBubbleWidth,
30323047 themeAppearance,
3048 usesNativeWorkspaceColumns,
30333049 userBubbleMaxWidth,
30343050 markdownContentWidth,
30353051 contentWidth,
......@@ -3067,6 +3083,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
30673083 reviewCommentColors,
30683084 reviewCommentBubbleWidth,
30693085 themeAppearance,
3086 usesNativeWorkspaceColumns,
30703087 userBubbleMaxWidth,
30713088 markdownContentWidth,
30723089 contentWidth,
......@@ -3119,7 +3136,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
31193136 // (facebook/react-native#54123); the anchored end space after a send
31203137 // is pure inset, so without this the blank region can't be scrolled.
31213138 applyWorkaroundForContentInsetHitTestBug
3122 contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"}
3139 // Horizontal Duo reservations are already included in the row padding.
3140 // Let UIKit adjust the scrolling axis without shifting content sideways.
3141 contentInsetAdjustmentBehavior={
3142 usesNativeAutomaticInsets
3143 ? usesNativeWorkspaceColumns &&
3144 Platform.OS === "ios" &&
3145 !Platform.isPad &&
3146 props.layoutVariant === "split"
3147 ? "scrollableAxes"
3148 : "automatic"
3149 : "never"
3150 }
31233151 automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets}
31243152 {...(usesNativeAutomaticInsets
31253153 ? {
......@@ -3231,7 +3259,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
32313259 }
32323260 contentContainerStyle={{
32333261 paddingTop: 12,
3234 paddingHorizontal: contentHorizontalPadding,
3262 paddingLeft: contentHorizontalPadding + contentLeftInset,
3263 paddingRight: contentHorizontalPadding + contentRightInset,
32353264 }}
32363265 />
32373266 </View>
......@@ -3239,7 +3268,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
32393268 !props.worktreeSetup &&
32403269 props.activeWorkStartedAt === null &&
32413270 props.contentPresentation.kind === "ready" ? (
3242 <View pointerEvents="none" style={StyleSheet.absoluteFill}>
3271 <View
3272 pointerEvents="none"
3273 style={[StyleSheet.absoluteFill, { left: contentLeftInset, right: contentRightInset }]}
3274 >
32433275 <ThreadFeedPlaceholder
32443276 title="No conversation yet"
32453277 detail="Ask the agent to inspect the repo, run a command, or continue the active thread."
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+6-6
......@@ -73,8 +73,8 @@ import {
7373import {
7474 createNativeMailSearchToolbarItem,
7575 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
76 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
7776} from "../layout/native-mail-search-toolbar";
77import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
7878import { ModelRow, ChoiceRow } from "./ThreadSettingsRows";
7979import {
8080 compatibleRuntimeModeForChoices,
......@@ -742,10 +742,10 @@ function ThreadSettingsOptionsItem(props: {
742742 .get(session.environmentId)
743743 ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null)
744744 : null;
745 const bottomToolbarInset =
746 Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
747 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET
748 : 0;
745 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
746 const bottomToolbarInset = usesNativeMailSearchToolbar
747 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET
748 : 0;
749749
750750 return (
751751 <View style={{ paddingBottom: insets.bottom + bottomToolbarInset + 12 }}>
......@@ -1107,7 +1107,7 @@ function ThreadSettingsModelsScreen() {
11071107 const session = useThreadSettingsSession();
11081108 const presentation = useThreadSettingsPickerPresentation();
11091109 const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>();
1110 const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
1110 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
11111111 const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy;
11121112 const commitAndClose = useCallback(() => {
11131113 if (!session.commitPendingModel()) return;
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+3-2
......@@ -21,6 +21,7 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re
2121
2222import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
2323import { useSafeAreaInsets } from "react-native-safe-area-context";
24import { NativeSheetContent } from "../../../native/NativeSheetContent";
2425import { useUniwindTheme } from "../../../lib/useUniwindTheme";
2526import {
2627 AndroidHeaderIconButton,
......@@ -411,7 +412,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
411412 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
412413 style={{ backgroundColor: sheetColor, flex: 1 }}
413414 >
414 {content}
415 <NativeSheetContent>{content}</NativeSheetContent>
415416 <ScreenStackHeaderConfig
416417 backgroundColor="rgba(0,0,0,0)"
417418 color={foregroundColor}
......@@ -447,7 +448,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
447448 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
448449 style={{ backgroundColor: sheetColor, flex: 1 }}
449450 >
450 {content}
451 <NativeSheetContent>{content}</NativeSheetContent>
451452 <ScreenStackHeaderConfig
452453 backgroundColor="rgba(0,0,0,0)"
453454 color={foregroundColor}
apps/mobile/src/features/threads/sidebar-native-header-items.ts+5-2
......@@ -34,7 +34,8 @@ function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHead
3434/**
3535 * Right-side UINavigationBar items for the sidebar column: the thread list
3636 * filter/sort menu plus the settings button, sharing one glass capsule —
37 * the Messages-style grouped header buttons.
37 * the Messages-style grouped header buttons. The embedded sidebar keeps these
38 * beside its title; only the detail column uses the Duo's vertical bar.
3839 */
3940export function createSidebarHeaderItems(input: {
4041 readonly filterIcon: string;
......@@ -44,6 +45,7 @@ export function createSidebarHeaderItems(input: {
4445 return [
4546 withNativeGlassHeaderItem({
4647 type: "menu",
48 axisBehavior: "horizontalOnly",
4749 label: "",
4850 accessibilityLabel: "Filter threads",
4951 icon: sfSymbolIcon(input.filterIcon),
......@@ -54,7 +56,8 @@ export function createSidebarHeaderItems(input: {
5456 }),
5557 withNativeGlassHeaderItem({
5658 type: "button",
57 label: "",
59 axisBehavior: "horizontalOnly",
60 label: "Settings",
5861 accessibilityLabel: "Open settings",
5962 icon: sfSymbolIcon("gearshape"),
6063 onPress: input.onOpenSettings,
apps/mobile/src/features/threads/useThreadHeaderOptions.tsx+83-16
......@@ -1,8 +1,11 @@
11import { StackActions, useNavigation } from "@react-navigation/native";
22import { useMemo } from "react";
3import { Platform } from "react-native";
34import type { AppNativeStackNavigationOptions } from "../../native/StackHeader";
5import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
46import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
57import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
8import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
69import {
710 ThreadGitControls,
811 useThreadGitCenterHeaderItems,
......@@ -19,18 +22,24 @@ export function useThreadHeaderOptions(props: {
1922 readonly gitControls: Parameters<typeof ThreadGitControls>[0];
2023 readonly onReturnToThread?: () => void;
2124}) {
25 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
26 const usesDuoHeader = usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad;
2227 const navigation = useNavigation();
2328 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
2429 const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls);
2530 const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls);
2631 const splitLeftHeaderItems = useMemo<NativeHeaderItems>(
2732 () => [
28 {
29 // Match Mail's split-view detail toolbar: the first detail action sits
30 // inside the content pane, not flush against the sidebar divider.
31 spacing: 18,
32 type: "spacing" as const,
33 },
33 ...(!usesDuoHeader
34 ? [
35 {
36 // Match Mail's split-view detail toolbar: the first detail action sits
37 // inside the content pane, not flush against the sidebar divider.
38 spacing: 18,
39 type: "spacing" as const,
40 },
41 ]
42 : []),
3443 ...(props.onReturnToThread
3544 ? [
3645 withNativeGlassHeaderItem({
......@@ -43,6 +52,7 @@ export function useThreadHeaderOptions(props: {
4352 ]
4453 : []),
4554 withNativeGlassHeaderItem({
55 axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined,
4656 accessibilityLabel: panes.primarySidebarVisible
4757 ? "Maximize content"
4858 : "Show thread sidebar",
......@@ -51,18 +61,31 @@ export function useThreadHeaderOptions(props: {
5161 type: "sfSymbol" as const,
5262 },
5363 identifier: "thread-left-sidebar",
64 label: panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar",
5465 onPress: togglePrimarySidebar,
5566 type: "button" as const,
5667 }),
57 withNativeGlassHeaderItem({
58 accessibilityLabel: "New task",
59 icon: { name: "square.and.pencil", type: "sfSymbol" as const },
60 identifier: "thread-left-new-task",
61 onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
62 type: "button" as const,
63 }),
68 ...(!usesDuoHeader
69 ? [
70 withNativeGlassHeaderItem({
71 accessibilityLabel: "New task",
72 icon: { name: "square.and.pencil", type: "sfSymbol" as const },
73 identifier: "thread-left-new-task",
74 label: "New task",
75 onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
76 type: "button" as const,
77 }),
78 ]
79 : []),
80 ],
81 [
82 panes.primarySidebarVisible,
83 props.onReturnToThread,
84 navigation,
85 togglePrimarySidebar,
86 usesDuoHeader,
87 usesNativeWorkspaceColumns,
6488 ],
65 [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
6689 );
6790 // Deep links / cold starts land with Thread as the ONLY route, where the
6891 // native back button does not render. Provide an explicit Home escape for
......@@ -81,6 +104,41 @@ export function useThreadHeaderOptions(props: {
81104 [navigation],
82105 );
83106
107 const duoRightHeaderItems = useMemo<NativeHeaderItems>(
108 () => [
109 ...threadCenterHeaderItems,
110 { type: "spacing", spacing: 8 },
111 ...(layout.usesSplitView &&
112 Platform.OS === "ios" &&
113 !Platform.isPad &&
114 !panes.primarySidebarVisible
115 ? [
116 withNativeGlassHeaderItem({
117 type: "button" as const,
118 axisBehavior: "verticalPreferred",
119 identifier: "thread-right-search",
120 label: "Search threads",
121 accessibilityLabel: "Search threads",
122 icon: { name: "magnifyingglass", type: "sfSymbol" as const },
123 onPress: () => {
124 dispatchHardwareKeyboardCommand("focusSearch");
125 },
126 }),
127 ]
128 : []),
129 withNativeGlassHeaderItem({
130 type: "button" as const,
131 axisBehavior: "verticalPreferred",
132 identifier: "thread-right-new-task",
133 label: "New task",
134 accessibilityLabel: "New task",
135 icon: { name: "square.and.pencil", type: "sfSymbol" as const },
136 onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
137 }),
138 ],
139 [navigation, threadCenterHeaderItems, layout.usesSplitView, panes.primarySidebarVisible],
140 );
141
84142 const options: AppNativeStackNavigationOptions = {
85143 headerShown: true,
86144 headerTitle: props.title,
......@@ -104,7 +162,12 @@ export function useThreadHeaderOptions(props: {
104162 // the git controls on the RIGHT (no center items — center space is
105163 // reserved for future breadcrumbs/status).
106164 unstable_headerRightItems: () =>
107 layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems,
165 usesDuoHeader
166 ? duoRightHeaderItems
167 : layout.usesSplitView
168 ? threadCenterHeaderItems
169 : compactRightHeaderItems,
170 unstable_headerToolbarItems: () => [],
108171 unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined,
109172 contentStyle: undefined,
110173 };
......@@ -116,7 +179,11 @@ export function useThreadHeaderOptions(props: {
116179 // callbacks also read state the items do not display (a "Push" item runs `push` or
117180 // `commit_push` depending on the default ref), so that state is keyed too.
118181 optionsVersion: [
119 layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems,
182 splitLeftHeaderItems,
183 threadCenterHeaderItems,
184 compactRightHeaderItems,
185 duoRightHeaderItems,
186 usesDuoHeader,
120187 environmentId,
121188 threadId,
122189 gitStatus?.isDefaultRef,
apps/mobile/src/lib/layout.ts+37-10
......@@ -1,5 +1,6 @@
11import { scaledTypographyLineHeight } from "./appearancePreferences";
22import { MOBILE_TYPOGRAPHY } from "./typography";
3import type { NativeLayoutMetrics } from "./reserved-regions";
34
45function clamp(value: number, min: number, max: number): number {
56 return Math.min(Math.max(value, min), max);
......@@ -56,6 +57,7 @@ export interface Layout {
5657 readonly variant: LayoutVariant;
5758 readonly usesSplitView: boolean;
5859 readonly listPaneWidth: number | null;
60 readonly listPaneGap?: number;
5961 readonly shellPadding: number;
6062}
6163
......@@ -87,9 +89,25 @@ export function deriveThreadFeedInitialContentInset(input: {
8789
8890export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector";
8991
90export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout {
92export function deriveLayout(input: {
93 readonly width: number;
94 readonly height: number;
95 readonly nativeMetrics?: NativeLayoutMetrics | null;
96}): Layout {
9197 const { width, height } = input;
92 const wideEnoughForSplit = width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT;
98 const metrics = input.nativeMetrics;
99 const usableWidth = width - (metrics?.safeArea.left ?? 0) - (metrics?.safeArea.right ?? 0);
100 // UIKit's size class supports the shorter Duo inner display. Geometry remains
101 // a floor so a narrow multitasking window cannot squeeze both columns.
102 const hasDuoGeometry =
103 metrics &&
104 (metrics.verticalBarEdge !== "none" ||
105 metrics.reservedRegions.some((region) => region.kind === "division"));
106 const wideEnoughForSplit = hasDuoGeometry
107 ? metrics.horizontalSizeClass === "regular" &&
108 usableWidth >= SPLIT_SIDEBAR_MIN_WIDTH + 320 &&
109 height >= 400
110 : width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT;
93111
94112 if (!wideEnoughForSplit) {
95113 return {
......@@ -100,14 +118,20 @@ export function deriveLayout(input: { readonly width: number; readonly height: n
100118 };
101119 }
102120
121 const division = metrics?.reservedRegions.find(
122 (region) =>
123 region.kind === "division" &&
124 region.height >= height / 2 &&
125 region.x - metrics.safeArea.left >= SPLIT_SIDEBAR_MIN_WIDTH &&
126 width - region.x - region.width - metrics.safeArea.right >= 320,
127 );
103128 return {
104129 variant: "split",
105130 usesSplitView: true,
106 listPaneWidth: clamp(
107 Math.round(width * 0.32),
108 SPLIT_SIDEBAR_MIN_WIDTH,
109 SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH,
110 ),
131 listPaneWidth:
132 division?.x ??
133 clamp(Math.round(width * 0.32), SPLIT_SIDEBAR_MIN_WIDTH, SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH),
134 ...(division ? { listPaneGap: division.width } : {}),
111135 shellPadding: 0,
112136 };
113137}
......@@ -125,7 +149,7 @@ export function deriveWorkspacePaneLayout(input: {
125149 const preferredPrimarySidebarVisible =
126150 input.layout.usesSplitView && input.primarySidebarPreferredVisible;
127151 const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible
128 ? (input.layout.listPaneWidth ?? 0)
152 ? (input.layout.listPaneWidth ?? 0) + (input.layout.listPaneGap ?? 0)
129153 : 0;
130154
131155 if (auxiliaryPaneRole === "inspector") {
......@@ -141,11 +165,14 @@ export function deriveWorkspacePaneLayout(input: {
141165 auxiliaryPaneVisible &&
142166 fileInspector.width !== null &&
143167 input.layout.listPaneWidth !== null &&
144 viewportWidth - input.layout.listPaneWidth - fileInspector.width <
168 viewportWidth -
169 input.layout.listPaneWidth -
170 (input.layout.listPaneGap ?? 0) -
171 fileInspector.width <
145172 FILE_INSPECTOR_MIN_MAIN_WIDTH;
146173 const primarySidebarVisible =
147174 preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary;
148 const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0;
175 const primarySidebarWidth = primarySidebarVisible ? preferredPrimarySidebarWidth : 0;
149176
150177 return {
151178 primarySidebarVisible,
apps/mobile/src/lib/reserved-regions.test.ts created+140
......@@ -0,0 +1,140 @@
1import { describe, expect, it } from "vite-plus/test";
2import { deriveBottomControlInsets, type NativeLayoutMetrics } from "./reserved-regions";
3import { deriveLayout, deriveWorkspacePaneLayout } from "./layout";
4
5const innerDisplay: NativeLayoutMetrics = {
6 width: 960,
7 height: 560,
8 horizontalSizeClass: "regular",
9 verticalBarEdge: "right",
10 safeArea: { top: 0, bottom: 20, left: 8, right: 72 },
11 reservedRegions: [],
12};
13
14describe("bottom controls around reserved regions", () => {
15 it("respects asymmetric side controls without reserving the bottom twice", () => {
16 expect(deriveBottomControlInsets(innerDisplay)).toEqual({ left: 8, right: 72, bottom: 0 });
17 });
18
19 it("leaves ordinary non-native clients unchanged", () => {
20 expect(deriveBottomControlInsets(null)).toEqual({ left: 0, right: 0, bottom: 0 });
21 });
22
23 it("keeps the editor entirely on one side of a book fold", () => {
24 const insets = deriveBottomControlInsets({
25 ...innerDisplay,
26 reservedRegions: [{ kind: "division", x: 456, y: 0, width: 48, height: 560 }],
27 });
28 expect(insets).toEqual({ left: 8, right: 504, bottom: 0 });
29 expect(innerDisplay.width - insets.left - insets.right).toBeGreaterThan(320);
30 });
31
32 it("keeps controls below a horizontal fold in a hands-free pose", () => {
33 expect(
34 deriveBottomControlInsets({
35 ...innerDisplay,
36 reservedRegions: [{ kind: "division", x: 0, y: 240, width: 960, height: 40 }],
37 }),
38 ).toEqual({ left: 8, right: 72, bottom: 0 });
39 });
40
41 it("moves a control away from a bottom camera while leaving a top camera alone", () => {
42 expect(
43 deriveBottomControlInsets({
44 ...innerDisplay,
45 reservedRegions: [
46 { kind: "occlusion", x: 8, y: 0, width: 60, height: 60 },
47 { kind: "occlusion", x: 8, y: 510, width: 60, height: 50 },
48 ],
49 }),
50 ).toEqual({ left: 68, right: 72, bottom: 0 });
51 });
52
53 it("restores the full editor width when the device opens flat", () => {
54 const folded = {
55 ...innerDisplay,
56 reservedRegions: [{ kind: "division" as const, x: 456, y: 0, width: 48, height: 560 }],
57 };
58 expect(deriveBottomControlInsets(folded)).not.toEqual(deriveBottomControlInsets(innerDisplay));
59 expect(deriveBottomControlInsets(innerDisplay)).toEqual({ left: 8, right: 72, bottom: 0 });
60 });
61});
62
63describe("Duo workspace sizing", () => {
64 it("shows both columns on a short regular-width inner display", () => {
65 const layout = deriveLayout({ ...innerDisplay, nativeMetrics: innerDisplay });
66 expect(layout.usesSplitView).toBe(true);
67 expect(
68 deriveWorkspacePaneLayout({
69 layout,
70 viewportWidth: innerDisplay.width,
71 primarySidebarPreferredVisible: true,
72 auxiliaryPanePreferredVisible: false,
73 }).contentPaneWidth,
74 ).toBeGreaterThan(320);
75 });
76
77 it("keeps an ordinary phone compact when it rotates", () => {
78 expect(deriveLayout({ width: 852, height: 393 }).usesSplitView).toBe(false);
79 for (const [width, height] of [
80 [932, 430],
81 [650, 500],
82 ]) {
83 const metrics: NativeLayoutMetrics = {
84 ...innerDisplay,
85 width: width!,
86 height: height!,
87 verticalBarEdge: "none",
88 safeArea: { top: 0, bottom: 20, left: 59, right: 59 },
89 };
90 expect(deriveLayout({ ...metrics, nativeMetrics: metrics }).usesSplitView).toBe(false);
91 const cameraMetrics = {
92 ...metrics,
93 reservedRegions: [{ kind: "occlusion" as const, x: 0, y: 0, width: 60, height: 60 }],
94 };
95 expect(deriveLayout({ ...cameraMetrics, nativeMetrics: cameraMetrics }).usesSplitView).toBe(
96 false,
97 );
98 }
99 expect(
100 deriveLayout({
101 width: 960,
102 height: 560,
103 nativeMetrics: {
104 ...innerDisplay,
105 horizontalSizeClass: "compact",
106 },
107 }).usesSplitView,
108 ).toBe(false);
109 });
110
111 it("requires enough safe width for both panes in a multitasking window", () => {
112 const metrics = { ...innerDisplay, width: 660 };
113 expect(deriveLayout({ ...metrics, nativeMetrics: metrics }).usesSplitView).toBe(false);
114 });
115
116 it("aligns the panes to the book fold and accounts for the reserved gap", () => {
117 const metrics = {
118 ...innerDisplay,
119 reservedRegions: [{ kind: "division" as const, x: 456, y: 0, width: 48, height: 560 }],
120 };
121 const layout = deriveLayout({ ...metrics, nativeMetrics: metrics });
122 expect(layout.listPaneWidth).toBe(456);
123 expect(layout.listPaneGap).toBe(48);
124 const panes = deriveWorkspacePaneLayout({
125 layout,
126 viewportWidth: 960,
127 primarySidebarPreferredVisible: true,
128 auxiliaryPanePreferredVisible: false,
129 });
130 expect(panes.contentPaneWidth).toBe(456);
131 expect(
132 deriveWorkspacePaneLayout({
133 layout,
134 viewportWidth: 960,
135 primarySidebarPreferredVisible: false,
136 auxiliaryPanePreferredVisible: false,
137 }).contentPaneWidth,
138 ).toBe(960);
139 });
140});
apps/mobile/src/lib/reserved-regions.ts+40-1
......@@ -1,12 +1,51 @@
1/** Geometry of the body inside a native navigation column. */
1export interface ReservedRegion {
2 readonly kind: "division" | "occlusion";
3 readonly x: number;
4 readonly y: number;
5 readonly width: number;
6 readonly height: number;
7}
8
29export interface NativeLayoutMetrics {
310 readonly width: number;
411 readonly height: number;
12 readonly hasHinge?: boolean;
513 readonly horizontalSizeClass: "compact" | "regular";
14 readonly verticalBarEdge: "none" | "left" | "right";
615 readonly safeArea: {
716 readonly top: number;
817 readonly bottom: number;
918 readonly left: number;
1019 readonly right: number;
1120 };
21 readonly reservedRegions: ReadonlyArray<ReservedRegion>;
22}
23
24/**
25 * Keep bottom controls in one usable region. A book-like fold selects the
26 * larger side; a horizontal fold leaves the controls on the lower display.
27 * Text and other scrollable content can continue to use the full canvas.
28 */
29export function deriveBottomControlInsets(metrics: NativeLayoutMetrics | null) {
30 if (!metrics) return { left: 0, right: 0, bottom: 0 };
31 let left = metrics.safeArea.left;
32 let right = metrics.width - metrics.safeArea.right;
33 let bottom = metrics.height;
34 for (const region of metrics.reservedRegions) {
35 const x = Math.max(left, region.x);
36 const endX = Math.min(right, region.x + region.width);
37 const y = Math.max(0, region.y);
38 const endY = Math.min(bottom, region.y + region.height);
39 if (endX <= x || endY <= y) continue;
40 if (region.kind === "division" && region.height >= metrics.height / 2) {
41 if (right - endX >= x - left) left = endX;
42 else right = x;
43 } else if (endY >= bottom - metrics.safeArea.bottom) {
44 // An occlusion at the bottom can also occupy just one corner.
45 if (region.width >= right - left) bottom = y;
46 else if (right - endX >= x - left) left = endX;
47 else right = x;
48 }
49 }
50 return { left, right: metrics.width - right, bottom: metrics.height - bottom };
1251}
apps/mobile/src/native/NativeColumnContent.ios.tsx+5-1
......@@ -9,12 +9,16 @@ import { NativeLayoutObserver } from "./NativeLayoutObserver";
99export function NativeColumnContent(props: {
1010 readonly children: ReactNode;
1111 readonly primary?: boolean;
12 readonly insetHorizontally?: boolean;
1213}) {
1314 const [metrics, setMetrics] = useState<NativeLayoutMetrics | null>(null);
1415 return (
1516 <View className={props.primary ? "flex-1 bg-drawer" : "flex-1 bg-screen"}>
1617 <SafeAreaProvider style={{ flex: 1 }}>
17 <SafeAreaView edges={["left", "right"]} style={{ flex: 1 }}>
18 <SafeAreaView
19 edges={props.insetHorizontally === false ? [] : ["left", "right"]}
20 style={{ flex: 1 }}
21 >
1822 {/* Observe the inset body. UIKit can reserve other columns in its safe area. */}
1923 <View style={{ flex: 1 }}>
2024 <NativeLayoutObserver onChange={setMetrics} />
apps/mobile/src/native/NativeHeaderToolbar.tsx+25-7
......@@ -14,6 +14,7 @@ import {
1414 type ReactNode,
1515} from "react";
1616import type { ColorValue } from "react-native";
17import type { HeaderBarButtonSearchBarPlacementItem } from "react-native-screens";
1718
1819function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null {
1920 return useNavigation<NativeStackNavigationProp<ParamListBase>>();
......@@ -34,8 +35,13 @@ function labelFromChildren(children: ReactNode): string {
3435type NativeStackHeaderIcon = NonNullable<
3536 Extract<NativeStackHeaderItem, { type: "button" }>["icon"]
3637>;
38type NativeStackHeaderAxisBehavior = Extract<
39 NativeStackHeaderItem,
40 { type: "button" }
41>["axisBehavior"];
42type NativeToolbarItem = NativeStackHeaderItem | HeaderBarButtonSearchBarPlacementItem;
3743type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {
38 unstable_headerToolbarItems?: () => NativeStackHeaderItem[];
44 unstable_headerToolbarItems?: () => NativeToolbarItem[];
3945};
4046
4147function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {
......@@ -112,7 +118,7 @@ function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"
112118 return items;
113119}
114120
115function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
121function convertToolbarChild(child: ReactNode): NativeToolbarItem | null {
116122 if (!isValidElement<ToolbarElementProps>(child)) {
117123 return null;
118124 }
......@@ -121,15 +127,22 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
121127 if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) {
122128 return { type: "custom", element: child.props.children };
123129 }
130 if (typeName === "NativeHeaderToolbarSearchBarSlot") {
131 return { type: "searchBarPlacement" };
132 }
124133
125134 if (typeName === "NativeHeaderToolbarButton") {
126135 return {
127136 type: "button",
128 label: typeof child.props.label === "string" ? child.props.label : "",
137 label:
138 typeof child.props.label === "string"
139 ? child.props.label
140 : String(child.props.accessibilityLabel ?? ""),
129141 accessibilityLabel:
130142 typeof child.props.accessibilityLabel === "string"
131143 ? child.props.accessibilityLabel
132144 : undefined,
145 axisBehavior: child.props.axisBehavior as NativeStackHeaderAxisBehavior,
133146 disabled: Boolean(child.props.disabled),
134147 icon: iconFromProp(child.props.icon),
135148 onPress:
......@@ -173,8 +186,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
173186 return null;
174187}
175188
176function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] {
177 const items: NativeStackHeaderItem[] = [];
189function collectToolbarItems(children: ReactNode): NativeToolbarItem[] {
190 const items: NativeToolbarItem[] = [];
178191 Children.forEach(children, (child) => {
179192 const item = convertToolbarChild(child);
180193 if (item) {
......@@ -211,13 +224,17 @@ function NativeHeaderToolbarRoot(props: {
211224 } as NativeStackOptionsWithToolbar);
212225 };
213226 }
227 // UIKit's search placement item belongs to the toolbar, never a header group.
228 const headerItems = items.filter(
229 (item): item is NativeStackHeaderItem => item.type !== "searchBarPlacement",
230 );
214231 if (props.placement === "left") {
215 navigation.setOptions({ unstable_headerLeftItems: () => items });
232 navigation.setOptions({ unstable_headerLeftItems: () => headerItems });
216233 return () => {
217234 navigation.setOptions({ unstable_headerLeftItems: () => [] });
218235 };
219236 }
220 navigation.setOptions({ unstable_headerRightItems: () => items });
237 navigation.setOptions({ unstable_headerRightItems: () => headerItems });
221238 return () => {
222239 navigation.setOptions({ unstable_headerRightItems: () => [] });
223240 };
......@@ -228,6 +245,7 @@ function NativeHeaderToolbarRoot(props: {
228245
229246function NativeHeaderToolbarButton(_props: {
230247 readonly accessibilityLabel?: string;
248 readonly axisBehavior?: NativeStackHeaderAxisBehavior;
231249 readonly disabled?: boolean;
232250 readonly icon?: string;
233251 readonly label?: string;
apps/mobile/src/native/NativeSheetContent.tsx created+19
......@@ -0,0 +1,19 @@
1import type { ReactNode } from "react";
2import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
3
4import { useNativeWorkspaceColumnsSupported } from "./NativeWorkspaceColumns";
5
6/** Reserve the sheet's own side bars without moving its native navigation chrome. */
7export function NativeSheetContent(props: { readonly children: ReactNode }) {
8 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
9 if (!usesNativeWorkspaceColumns) return <>{props.children}</>;
10
11 // A floating sheet can have different insets from the workspace underneath it.
12 return (
13 <SafeAreaProvider style={{ flex: 1 }}>
14 <SafeAreaView edges={["left", "right"]} style={{ flex: 1 }}>
15 {props.children}
16 </SafeAreaView>
17 </SafeAreaProvider>
18 );
19}
apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx+25-3
......@@ -1,7 +1,29 @@
11import { requireOptionalNativeModule } from "expo";
22import { Platform } from "react-native";
3const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>(
4 "T3NativeControls",
5);
3import { useNativeLayoutMetrics } from "./native-layout-metrics";
4
5const nativeControls = requireOptionalNativeModule<{
6 readonly supportsWorkspaceColumns?: boolean;
7 readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown };
8}>("T3NativeControls");
69export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED =
710 Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true;
11
12export function useNativeWorkspaceColumnsReady() {
13 const metrics = useNativeLayoutMetrics();
14 // Choose the phone's native host before attaching any screens. Older clients
15 // without the observer can immediately use the compact navigator.
16 return (
17 (Platform.OS === "ios" && Platform.isPad) ||
18 !nativeControls?.ViewPrototypes?.T3NativeControls_LayoutMetrics ||
19 metrics !== null
20 );
21}
22
23export function useNativeWorkspaceColumnsSupported() {
24 const metrics = useNativeLayoutMetrics();
25 return (
26 NATIVE_WORKSPACE_COLUMNS_SUPPORTED ||
27 (nativeControls?.supportsWorkspaceColumns === true && metrics?.hasHinge === true)
28 );
29}
apps/mobile/src/native/NativeWorkspaceColumns.tsx+8
......@@ -1,3 +1,11 @@
11import { Platform } from "react-native";
22// Android retains its existing navigator. The iOS file checks native availability.
33export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad;
4
5export function useNativeWorkspaceColumnsReady() {
6 return true;
7}
8
9export function useNativeWorkspaceColumnsSupported() {
10 return NATIVE_WORKSPACE_COLUMNS_SUPPORTED;
11}
apps/mobile/src/native/StackHeader.tsx+2
......@@ -19,6 +19,8 @@ export type AppNativeStackNavigationOptions = Omit<
1919 NativeStackNavigationOptions,
2020 "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems"
2121> & {
22 /** Nested navigator leaves apply their own horizontal safe area. */
23 readonly nativeContentInsetHorizontally?: boolean;
2224 readonly headerTintColor?: string | ColorValue;
2325 readonly unstable_headerCenterItems?: unknown;
2426 readonly unstable_headerLeftItems?: unknown;
apps/mobile/src/native/V5StackHeader.ios.tsx+23-1
......@@ -6,6 +6,7 @@ import type {
66import { isValidElement, useRef } from "react";
77import { Platform, StyleSheet } from "react-native";
88import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme";
9import { useNativeLayoutMetrics } from "../native/native-layout-metrics";
910import {
1011 SearchBar,
1112 Stack,
......@@ -110,6 +111,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte
110111 id,
111112 identifier: item.identifier,
112113 title: item.label || item.accessibilityLabel || undefined,
114 axisBehavior: item.axisBehavior,
113115 icon: convertIcon(item.icon),
114116 menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`),
115117 hidesSharedBackground: item.hidesSharedBackground,
......@@ -120,6 +122,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte
120122 id,
121123 identifier: item.identifier,
122124 title: item.label || item.accessibilityLabel || undefined,
125 axisBehavior: item.axisBehavior,
123126 icon: convertIcon(item.icon),
124127 onPress: item.disabled ? undefined : item.onPress,
125128 hidesSharedBackground: item.hidesSharedBackground,
......@@ -136,6 +139,7 @@ export function V5StackHeader(props: {
136139}) {
137140 const { options } = props;
138141 const theme = useMobileNavigationTheme();
142 const nativeMetrics = useNativeLayoutMetrics();
139143 const searchRef = useRef<SearchBarCommands>(null);
140144 const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack };
141145 const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading");
......@@ -162,6 +166,7 @@ export function V5StackHeader(props: {
162166 bottom.push({
163167 type: "item",
164168 id: mailSearch.filterButtonId ?? "filter",
169 axisBehavior: bottomSearch ? "horizontalOnly" : undefined,
165170 icon: {
166171 type: "sfSymbol",
167172 name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease",
......@@ -171,7 +176,12 @@ export function V5StackHeader(props: {
171176 if (bottomSearch) {
172177 if (bottom.length)
173178 bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 });
174 bottom.push({ type: "item", id: "home-search", searchBarPlacement: true });
179 bottom.push({
180 type: "item",
181 id: "home-search",
182 axisBehavior: "horizontalOnly",
183 searchBarPlacement: true,
184 });
175185 if (mailSearch.onComposePress)
176186 bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 });
177187 }
......@@ -179,10 +189,22 @@ export function V5StackHeader(props: {
179189 bottom.push({
180190 type: "item",
181191 id: mailSearch.composeButtonId ?? "compose",
192 axisBehavior: bottomSearch ? "horizontalOnly" : undefined,
182193 icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" },
183194 onPress: mailSearch.onComposePress,
184195 });
185196 }
197 // Expanded Duo panes put toolbar actions in the side bar. Folded phones
198 // keep their bottom search toolbar. UIKit keeps the
199 // primary column's navigation items beside its title and the detail's in
200 // the rail; a conventional UIToolbar can otherwise leave them inaccessible.
201 if (
202 nativeMetrics?.horizontalSizeClass === "regular" &&
203 nativeMetrics.verticalBarEdge !== "none"
204 ) {
205 trailing.push(...bottom);
206 bottom.length = 0;
207 }
186208 const left = options.headerLeft?.(itemProps);
187209 const right = options.headerRight?.(itemProps);
188210 if (!options.unstable_headerLeftItems && isValidElement(left))
apps/mobile/src/native/createV5StackNavigator.ios.tsx+9-1
......@@ -24,6 +24,7 @@ import { useCallback, useRef, useState, type ComponentProps } from "react";
2424import { StyleSheet, View } from "react-native";
2525import { FormSheet, Stack } from "react-native-screens";
2626import { V5StackHeader } from "./V5StackHeader.ios";
27import type { AppNativeStackNavigationOptions } from "./StackHeader";
2728import { NativeColumnContent } from "./NativeColumnContent.ios";
2829import {
2930 nativeStackPopAction,
......@@ -232,7 +233,14 @@ export function V5CardStackView(props: V5StackViewProps) {
232233 : index > 0
233234 }
234235 />
235 <NativeColumnContent>{descriptor.render()}</NativeColumnContent>
236 <NativeColumnContent
237 insetHorizontally={
238 (descriptor.options as AppNativeStackNavigationOptions)
239 .nativeContentInsetHorizontally
240 }
241 >
242 {descriptor.render()}
243 </NativeColumnContent>
236244 </NavigationRouteContext>
237245 </NavigationContext>
238246 </Stack.Screen>
apps/mobile/src/native/use-native-mail-search-toolbar.ts created+13
......@@ -0,0 +1,13 @@
1import { useNativeLayoutMetrics } from "./native-layout-metrics";
2import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native-glass";
3
4/** Folded phones keep search in the bottom toolbar; expanded Duo panes use the side bar. */
5export function useNativeMailSearchToolbar() {
6 const metrics = useNativeLayoutMetrics();
7 return (
8 NATIVE_LIQUID_GLASS_SUPPORTED &&
9 (metrics === null ||
10 metrics.horizontalSizeClass === "compact" ||
11 metrics.verticalBarEdge === "none")
12 );
13}
docs/operations/mobile-screens-fork.md+7-2
......@@ -1,7 +1,7 @@
11# Building the mobile Screens dependency
22
33The mobile v5 stack uses a committed tarball from the
4[T3 Screens fork](https://github.com/juliusmarminge/react-native-screens/tree/t3-v5.0.0-t3.7).
4[T3 Screens fork](https://github.com/juliusmarminge/react-native-screens/tree/t3-v5.0.0-t3.8).
55The fork owns the integration changes; the app does not patch this package.
66Each archive includes compiled JavaScript, declarations, native source and
77`t3-fork.json` recording its version, source commit and upstream base.
......@@ -9,7 +9,7 @@ Each archive includes compiled JavaScript, declarations, native source and
99To reproduce the current archive with Node 24 and the repository's pinned Yarn:
1010
1111```sh
12git clone --branch t3-v5.0.0-t3.7 https://github.com/juliusmarminge/react-native-screens.git /tmp/t3-screens-fork
12git clone --branch t3-v5.0.0-t3.8 https://github.com/juliusmarminge/react-native-screens.git /tmp/t3-screens-fork
1313yarn --cwd /tmp/t3-screens-fork pack:t3 /absolute/path/to/t3code/apps/mobile/deps
1414```
1515
......@@ -20,3 +20,8 @@ Run `pack:t3` from that clean checkout and tag the source as `t3-v<version>`.
2020Replace the old archive, update `apps/mobile/package.json` and run `vp i` to
2121regenerate the lockfile. Verify mobile types and affected navigation tests.
2222Changes to native source also require rebuilding and testing the native client.
23
24The same iOS binary supports iPhone, iPad, and Duo. UIKit hinge updates enable
25phone workspace columns at runtime; regular phones retain portrait rotation.
26Use the normal EAS build profiles and update channels. Duo support requires a
27binary built with the iOS 27.1 SDK; an OTA cannot add the native APIs.
patches/@react-navigation%2Fnative-stack@7.17.6.patch+10-2
......@@ -67,7 +67,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d
6767 }
6868 return item;
6969 }
70+ if (item.type === 'mailSearchToolbar') {
70+ if (item.type === 'mailSearchToolbar' || item.type === 'searchBarPlacement') {
7171+ return {
7272+ ...item,
7373+ index
......@@ -81,7 +81,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d
8181 return processedItem;
8282 }
8383- throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom' and 'spacing'.`);
84+ throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom', 'spacing' and 'mailSearchToolbar'.`);
84+ throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom', 'spacing', 'mailSearchToolbar' and 'searchBarPlacement'.`);
8585 }).filter(item => item != null);
8686 };
8787 const transformIcon = icon => {
......@@ -190,6 +190,10 @@ index 2f1351a89f0f2e67e854dc81e392198326b71834..ccf8556911fa1fa17588f06b56edb215
190190 /**
191191 * A React Element to display as the item.
192192 */
193@@ -864,1 +864,3 @@
194 sharesBackground?: boolean;
195+ /** Native bar axis preference on iOS 27.1 and later. */
196+ axisBehavior?: 'automatic' | 'horizontalOnly' | 'verticalPreferred';
193197diff --git a/src/types.tsx b/src/types.tsx
194198index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9eb16f1a80 100644
195199--- a/src/types.tsx
......@@ -208,6 +212,10 @@ index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9e
208212 /**
209213 * A React Element to display as the item.
210214 */
215@@ -920,1 +920,3 @@
216 sharesBackground?: boolean;
217+ /** Native bar axis preference on iOS 27.1 and later. */
218+ axisBehavior?: 'automatic' | 'horizontalOnly' | 'verticalPreferred';
211219diff --git a/src/views/useHeaderConfigProps.tsx b/src/views/useHeaderConfigProps.tsx
212220index 3f3fff405048b3c2250afbe99dd7f35cecb9a6d5..bb062b898cfa77f8c02f951dc9d81b51672d7d0e 100644
213221--- a/src/views/useHeaderConfigProps.tsx
pnpm-lock.yaml+10-10
......@@ -154,7 +154,7 @@ patchedDependencies:
154154 '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0
155155 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
156156 '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba
157 '@react-navigation/native-stack@7.17.6': 3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87
157 '@react-navigation/native-stack@7.17.6': d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5
158158 alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd
159159 dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4
160160 effect@4.0.2: ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688
......@@ -346,7 +346,7 @@ importers:
346346 version: 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
347347 '@react-navigation/native-stack':
348348 specifier: 7.17.6
349 version: 7.17.6(patch_hash=3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87)(141a742cd91f55378cde709a48a3af06)
349 version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(cc0272b81e52c9a28f5defc327cee1b5)
350350 '@shikijs/core':
351351 specifier: 4.2.0
352352 version: 4.2.0
......@@ -549,8 +549,8 @@ importers:
549549 specifier: ~5.9.1
550550 version: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
551551 react-native-screens:
552 specifier: file:deps/react-native-screens-5.0.0-t3.7.tgz
553 version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
552 specifier: file:deps/react-native-screens-5.0.0-t3.8.tgz
553 version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
554554 react-native-shiki-engine:
555555 specifier: ^0.3.12
556556 version: 0.3.12(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
......@@ -10798,9 +10798,9 @@ packages:
1079810798 react: '*'
1079910799 react-native: '*'
1080010800
10801 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz:
10802 resolution: {integrity: sha512-Di1+YTCuhWpk9helKBxkvXAVAQTCSRo24tpFBGXdbTAT4vPm2vGPbtPe+lnIXcePjNHbTEaI1ha5n9PqQKl5lg==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz}
10803 version: 5.0.0-t3.7
10801 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz:
10802 resolution: {integrity: sha512-sgvyU7L9I1iKfFF5p/oe84duBpA2rJmY/WTgHgKmSI2JGLhWBWQ5qWjo/7WRyZkt36sjOkdNGxwNGhx7dCPzJA==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz}
10803 version: 5.0.0-t3.8
1080410804 peerDependencies:
1080510805 react: '*'
1080610806 react-native: '*'
......@@ -15971,7 +15971,7 @@ snapshots:
1597115971 optionalDependencies:
1597215972 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
1597315973
15974 '@react-navigation/native-stack@7.17.6(patch_hash=3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87)(141a742cd91f55378cde709a48a3af06)':
15974 '@react-navigation/native-stack@7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(cc0272b81e52c9a28f5defc327cee1b5)':
1597515975 dependencies:
1597615976 '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be)
1597715977 '@react-navigation/native': 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
......@@ -15979,7 +15979,7 @@ snapshots:
1597915979 react: 19.3.0
1598015980 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
1598115981 react-native-safe-area-context: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
15982 react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
15982 react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
1598315983 sf-symbols-typescript: 2.2.0
1598415984 warn-once: 0.1.1
1598515985 transitivePeerDependencies:
......@@ -21967,7 +21967,7 @@ snapshots:
2196721967 react: 19.3.0
2196821968 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
2196921969
21970 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
21970 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
2197121971 dependencies:
2197221972 react: 19.3.0
2197321973 react-freeze: 1.0.4(react@19.3.0)