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 = {...@@ -264,6 +264,11 @@ const config: ExpoConfig = {
264 "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`],264 "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`],
265 },265 },
266 infoPlist: {266 infoPlist: {
267 UISupportedInterfaceOrientations: [
268 "UIInterfaceOrientationPortrait",
269 "UIInterfaceOrientationLandscapeLeft",
270 "UIInterfaceOrientationLandscapeRight",
271 ],
267 NSAppTransportSecurity: {272 NSAppTransportSecurity: {
268 NSAllowsArbitraryLoads: true,273 NSAllowsArbitraryLoads: true,
269 },274 },
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 @@...@@ -13,7 +13,10 @@
13 },13 },
14 "channel": "development",14 "channel": "development",
15 "developmentClient": true,15 "developmentClient": true,
16 "distribution": "internal"16 "distribution": "internal",
17 "ios": {
18 "image": "macos-tahoe-26.6-xcode-27.1"
19 }
17 },20 },
18 "preview": {21 "preview": {
19 "corepack": true,22 "corepack": true,
...@@ -23,7 +26,10 @@...@@ -23,7 +26,10 @@
23 },26 },
24 "channel": "preview",27 "channel": "preview",
25 "environment": "preview",28 "environment": "preview",
26 "distribution": "internal"29 "distribution": "internal",
30 "ios": {
31 "image": "macos-tahoe-26.6-xcode-27.1"
32 }
27 },33 },
28 "preview:dev": {34 "preview:dev": {
29 "corepack": true,35 "corepack": true,
...@@ -38,6 +44,9 @@...@@ -38,6 +44,9 @@
38 "distribution": "internal",44 "distribution": "internal",
39 "android": {45 "android": {
40 "buildType": "apk"46 "buildType": "apk"
47 },
48 "ios": {
49 "image": "macos-tahoe-26.6-xcode-27.1"
41 }50 }
42 },51 },
43 "v2-preview": {52 "v2-preview": {
...@@ -56,7 +65,10 @@...@@ -56,7 +65,10 @@
56 },65 },
57 "channel": "production",66 "channel": "production",
58 "environment": "production",67 "environment": "production",
59 "autoIncrement": true68 "autoIncrement": true,
69 "ios": {
70 "image": "macos-tahoe-26.6-xcode-27.1"
71 }
60 }72 }
61 },73 },
62 "submit": {74 "submit": {
apps/mobile/modules/t3-native-controls/expo-module.config.json+2-1
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1{1{
2 "platforms": ["apple", "android"],2 "platforms": ["apple", "android"],
3 "apple": {3 "apple": {
4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"]4 "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"],
5 "appDelegateSubscribers": ["T3WorkspaceOrientationSubscriber"]
5 },6 },
6 "android": {7 "android": {
7 "modules": [8 "modules": [
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+6
...@@ -17,9 +17,13 @@ public final class T3KeyboardCommandsModule: Module {...@@ -17,9 +17,13 @@ public final class T3KeyboardCommandsModule: Module {
17public final class T3KeyboardCommandsView: ExpoView {17public final class T3KeyboardCommandsView: ExpoView {
18 let onCommand = EventDispatcher()18 let onCommand = EventDispatcher()
19 private var enabledCommands = Set<String>()19 private var enabledCommands = Set<String>()
20 private let shortcutInputView = UIView(frame: .zero)
2021
21 public override var canBecomeFirstResponder: Bool { true }22 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
23 public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {27 public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
24 if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss),28 if action == #selector(openCommandPalette) || action == #selector(paletteNext) || action == #selector(palettePrevious) || action == #selector(paletteDismiss),
25 let input = window?.t3FirstResponder as? UITextInput,29 let input = window?.t3FirstResponder as? UITextInput,
...@@ -89,6 +93,8 @@ public final class T3KeyboardCommandsView: ExpoView {...@@ -89,6 +93,8 @@ public final class T3KeyboardCommandsView: ExpoView {
8993
90 public required init(appContext: AppContext? = nil) {94 public required init(appContext: AppContext? = nil) {
91 super.init(appContext: appContext)95 super.init(appContext: appContext)
96 inputAssistantItem.leadingBarButtonGroups = []
97 inputAssistantItem.trailingBarButtonGroups = []
92 NotificationCenter.default.addObserver(98 NotificationCenter.default.addObserver(
93 self,99 self,
94 selector: #selector(reclaimFirstResponderIfAvailable),100 selector: #selector(reclaimFirstResponderIfAvailable),
apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift+49-2
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1import ExpoModulesCore1import ExpoModulesCore
2import UIKit2import 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.
5final class T3LayoutMetricsView: ExpoView {6final class T3LayoutMetricsView: ExpoView {
6 let onMetricsChange = EventDispatcher()7 let onMetricsChange = EventDispatcher()
7 private var lastMetrics: NSDictionary?8 private var lastMetrics: NSDictionary?
9 private var hasHinge: Bool? = false
810
9 required init(appContext: AppContext? = nil) {11 required init(appContext: AppContext? = nil) {
10 super.init(appContext: appContext)12 super.init(appContext: appContext)
...@@ -12,6 +14,22 @@ final class T3LayoutMetricsView: ExpoView {...@@ -12,6 +14,22 @@ final class T3LayoutMetricsView: ExpoView {
12 registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in14 registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in
13 view.publishMetrics()15 view.publishMetrics()
14 }16 }
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
15 }33 }
1634
17 override func didMoveToWindow() {35 override func didMoveToWindow() {
...@@ -31,14 +49,43 @@ final class T3LayoutMetricsView: ExpoView {...@@ -31,14 +49,43 @@ final class T3LayoutMetricsView: ExpoView {
31 }49 }
3250
33 private func publishMetrics() {51 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
35 let metrics: [String: Any] = [79 let metrics: [String: Any] = [
36 "width": bounds.width, "height": bounds.height,80 "width": bounds.width, "height": bounds.height,
81 "hasHinge": hasHinge,
37 "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact",82 "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact",
83 "verticalBarEdge": verticalBarEdge,
38 "safeArea": [84 "safeArea": [
39 "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom,85 "top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom,
40 "left": safeAreaInsets.left, "right": safeAreaInsets.right,86 "left": safeAreaInsets.left, "right": safeAreaInsets.right,
41 ],87 ],
88 "reservedRegions": regions,
42 ]89 ]
43 let next = metrics as NSDictionary90 let next = metrics as NSDictionary
44 guard lastMetrics?.isEqual(next) != true else { return }91 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 {...@@ -13,7 +13,9 @@ public final class T3NativeControlsModule: Module {
13 public func definition() -> ModuleDefinition {13 public func definition() -> ModuleDefinition {
14 Constants {14 Constants {
15 if #available(iOS 26.0, *) {15 if #available(iOS 26.0, *) {
16 return ["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil]16 return [
17 "supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil,
18 ]
17 }19 }
18 return ["supportsWorkspaceColumns": false]20 return ["supportsWorkspaceColumns": false]
19 }21 }
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 @@...@@ -127,7 +127,7 @@
127 "react-native-nitro-modules": "0.35.9",127 "react-native-nitro-modules": "0.35.9",
128 "react-native-reanimated": "4.7.0",128 "react-native-reanimated": "4.7.0",
129 "react-native-safe-area-context": "~5.9.1",129 "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",
131 "react-native-shiki-engine": "^0.3.12",131 "react-native-shiki-engine": "^0.3.12",
132 "react-native-svg": "15.15.5",132 "react-native-svg": "15.15.5",
133 "react-native-webview": "14.0.1",133 "react-native-webview": "14.0.1",
apps/mobile/src/App.tsx+8-5
...@@ -27,6 +27,7 @@ import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";...@@ -27,6 +27,7 @@ import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
27import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";27import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
28import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider";28import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider";
29import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";29import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
30import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics";
3031
31import "../global.css";32import "../global.css";
3233
...@@ -91,11 +92,13 @@ function AppContent() {...@@ -91,11 +92,13 @@ function AppContent() {
91 header (glass buttons, title, materials) is forced light even when92 header (glass buttons, title, materials) is forced light even when
92 the system is in dark mode. */}93 the system is in dark mode. */}
93 <GlobalVoiceInputControl>94 <GlobalVoiceInputControl>
94 <IncomingShareProvider>95 <NativeLayoutMetricsProvider>
95 <Navigation linking={appLinking} theme={navigationTheme} />96 <IncomingShareProvider>
96 </IncomingShareProvider>97 <Navigation linking={appLinking} theme={navigationTheme} />
97 <ConfirmDialogHost />98 </IncomingShareProvider>
98 <ThreadArrangementHost />99 <ConfirmDialogHost />
100 <ThreadArrangementHost />
101 </NativeLayoutMetricsProvider>
99 </GlobalVoiceInputControl>102 </GlobalVoiceInputControl>
100 {/* Anchored-menu overlays render here — in-window, so the103 {/* Anchored-menu overlays render here — in-window, so the
101 keyboard stays up while a dropdown is open. */}104 keyboard stays up while a dropdown is open. */}
apps/mobile/src/Stack.tsx+9-1
...@@ -128,6 +128,7 @@ import {...@@ -128,6 +128,7 @@ import {
128} from "./features/sharing/incoming-share-presentation";128} from "./features/sharing/incoming-share-presentation";
129import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";129import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
130import { deriveLayout } from "./lib/layout";130import { deriveLayout } from "./lib/layout";
131import { useNativeLayoutMetrics } from "./native/native-layout-metrics";
131import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";132import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
132import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";133import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";
133import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";134import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
...@@ -192,6 +193,9 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {...@@ -192,6 +193,9 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {
192 presentation: "fullScreenModal",193 presentation: "fullScreenModal",
193};194};
194195
196// A navigator container leaves horizontal safe-area handling to its leaf screens.
197const NESTED_NAVIGATOR_OPTIONS = { nativeContentInsetHorizontally: false, headerShown: false };
198
195const SettingsContentStack = createV5SheetStackNavigator({199const SettingsContentStack = createV5SheetStackNavigator({
196 initialRouteName: "Settings",200 initialRouteName: "Settings",
197 screenOptions: {201 screenOptions: {
...@@ -410,6 +414,7 @@ const SettingsSheetStack = createV5SheetStackNavigator({...@@ -410,6 +414,7 @@ const SettingsSheetStack = createV5SheetStackNavigator({
410 screens: {414 screens: {
411 SettingsContent: createNativeStackScreen({415 SettingsContent: createNativeStackScreen({
412 screen: SettingsContentStack,416 screen: SettingsContentStack,
417 options: NESTED_NAVIGATOR_OPTIONS,
413 linking: "",418 linking: "",
414 layout: ({ children }) => (419 layout: ({ children }) => (
415 <SettingsEnvironmentFilterProvider>420 <SettingsEnvironmentFilterProvider>
...@@ -845,6 +850,7 @@ const RootStackConfig = createWorkspaceStackNavigator({...@@ -845,6 +850,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
845 screen: SettingsSheetStack,850 screen: SettingsSheetStack,
846 linking: "settings",851 linking: "settings",
847 options: {852 options: {
853 ...NESTED_NAVIGATOR_OPTIONS,
848 gestureEnabled: true,854 gestureEnabled: true,
849 headerShown: false,855 headerShown: false,
850 },856 },
...@@ -911,6 +917,7 @@ const RootStackConfig = createWorkspaceStackNavigator({...@@ -911,6 +917,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
911 </GuardedScreenLayout>917 </GuardedScreenLayout>
912 ),918 ),
913 options: {919 options: {
920 ...NESTED_NAVIGATOR_OPTIONS,
914 gestureEnabled: true,921 gestureEnabled: true,
915 headerShown: false,922 headerShown: false,
916 },923 },
...@@ -951,8 +958,9 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName:...@@ -951,8 +958,9 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName:
951958
952export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {959export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {
953 const { width, height } = useWindowDimensions();960 const { width, height } = useWindowDimensions();
961 const nativeMetrics = useNativeLayoutMetrics();
954 const usesWorkspaceFlowScreens =962 const usesWorkspaceFlowScreens =
955 Platform.OS === "android" || deriveLayout({ width, height }).usesSplitView;963 Platform.OS === "android" || deriveLayout({ width, height, nativeMetrics }).usesSplitView;
956964
957 return (965 return (
958 <Navigator966 <Navigator
apps/mobile/src/components/ScreenHeader.tsx+12-4
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";1import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
2import { useId } from "react";2import { useId } from "react";
3import { ActivityIndicator, View } from "react-native";3import { ActivityIndicator, Platform, View } from "react-native";
4import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";4import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";
5import { ScreenHeaderButton } from "./ScreenHeaderButton";5import { ScreenHeaderButton } from "./ScreenHeaderButton";
6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader";6import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader";
7import { useNativeWorkspaceColumnsSupported } from "../native/NativeWorkspaceColumns";
7import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout";8import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout";
8import {9import {
9 createNativeMailSearchToolbarItem,10 createNativeMailSearchToolbarItem,
...@@ -12,6 +13,7 @@ import {...@@ -12,6 +13,7 @@ import {
12import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";13import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
13import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types";14import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types";
14import type { AppSymbolName } from "./AppSymbol";15import type { AppSymbolName } from "./AppSymbol";
16import { useNativeMailSearchToolbar } from "../native/use-native-mail-search-toolbar";
1517
16function iosIcon(icon: AppSymbolName) {18function iosIcon(icon: AppSymbolName) {
17 return typeof icon === "string" ? icon : icon.ios;19 return typeof icon === "string" ? icon : icon.ios;
...@@ -33,15 +35,17 @@ function mailMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>): MailMenu["it...@@ -33,15 +35,17 @@ function mailMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>): MailMenu["it
33}35}
3436
35export function ScreenHeader(props: ScreenHeaderProps) {37export function ScreenHeader(props: ScreenHeaderProps) {
38 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
36 const headerId = useId();39 const headerId = useId();
37 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();40 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
38 const { themeVariables } = useAppearancePreferences();41 const { themeVariables } = useAppearancePreferences();
42 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
39 const { search, menus } = props;43 const { search, menus } = props;
40 const menu = menus?.[0];44 const menu = menus?.[0];
41 const compactSearch =45 const compactSearch =
42 search !== undefined &&46 search !== undefined &&
43 (search.compactToolbar ?? !layout.usesSplitView) &&47 (search.compactToolbar ?? !layout.usesSplitView) &&
44 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;48 usesNativeMailSearchToolbar;
45 const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []);49 const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []);
46 const refresh = search?.refreshInToolbar ? search.onRefresh : undefined;50 const refresh = search?.refreshInToolbar ? search.onRefresh : undefined;
47 return (51 return (
...@@ -88,7 +92,7 @@ export function ScreenHeader(props: ScreenHeaderProps) {...@@ -88,7 +92,7 @@ export function ScreenHeader(props: ScreenHeaderProps) {
88 : undefined),92 : undefined),
89 }),93 }),
90 ]94 ]
91 : undefined,95 : () => [],
92 headerSearchBarOptions: compactSearch96 headerSearchBarOptions: compactSearch
93 ? undefined97 ? undefined
94 : {98 : {
...@@ -116,8 +120,12 @@ export function ScreenHeader(props: ScreenHeaderProps) {...@@ -116,8 +120,12 @@ export function ScreenHeader(props: ScreenHeaderProps) {
116 onPress={props.backInSplitView.onPress ?? props.onBack}120 onPress={props.backInSplitView.onPress ?? props.onBack}
117 />121 />
118 ) : null}122 ) : null}
119 {props.sidebar !== false ? (123 {props.sidebar !== false &&
124 (!usesNativeWorkspaceColumns ||
125 (Platform.OS === "ios" && Platform.isPad) ||
126 !panes.primarySidebarVisible) ? (
120 <ScreenHeaderButton127 <ScreenHeaderButton
128 axisBehavior={usesNativeWorkspaceColumns ? "horizontalOnly" : undefined}
121 accessibilityLabel={129 accessibilityLabel={
122 panes.primarySidebarVisible130 panes.primarySidebarVisible
123 ? `Maximize ${props.title.toLowerCase()}`131 ? `Maximize ${props.title.toLowerCase()}`
apps/mobile/src/features/home/HomeHeader.tsx+34-11
...@@ -1,25 +1,43 @@...@@ -1,25 +1,43 @@
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";1import { 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";
3import { Platform } from "react-native";5import { Platform } from "react-native";
4import type { SearchBarCommands } from "react-native-screens";6import type { SearchBarCommands } from "react-native-screens";
5import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";7import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
6import { useUniwindTheme } from "../../lib/useUniwindTheme";8import { useUniwindTheme } from "../../lib/useUniwindTheme";
7import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";9import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
8import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";10import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
9import {11import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
10 createNativeMailSearchToolbarItem,12import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
11 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
12} from "../layout/native-mail-search-toolbar";
13import { buildHomeListFilterMenu } from "./home-list-filter-menu";13import { buildHomeListFilterMenu } from "./home-list-filter-menu";
14import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items";14import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items";
15import type { HomeHeaderProps } from "./HomeHeader.types";15import type { HomeHeaderProps } from "./HomeHeader.types";
16import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
1617
17export type { HomeHeaderEnvironment } from "./HomeHeader.types";18export type { HomeHeaderEnvironment } from "./HomeHeader.types";
1819
19export function HomeHeader(props: HomeHeaderProps) {20export function HomeHeader(props: HomeHeaderProps) {
21 const navigation = useNavigation<NativeStackNavigationProp<ParamListBase>>();
22 const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
23 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
20 const primaryColumn = use(NativePrimaryColumnContext);24 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);
22 const searchBarRef = useRef<SearchBarCommands>(null);29 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 );
23 const iconColor = useUniwindTheme()["--color-icon"];41 const iconColor = useUniwindTheme()["--color-icon"];
24 // The list uses a fixed creation order and ignores sort/group options, so42 // The list uses a fixed creation order and ignores sort/group options, so
25 // the filter menu only carries the filters and the "customized" icon state43 // the filter menu only carries the filters and the "customized" icon state
...@@ -27,9 +45,14 @@ export function HomeHeader(props: HomeHeaderProps) {...@@ -27,9 +45,14 @@ export function HomeHeader(props: HomeHeaderProps) {
27 const hasCustomListOptions =45 const hasCustomListOptions =
28 props.selectedEnvironmentId !== null || props.selectedProjectKey !== null;46 props.selectedEnvironmentId !== null || props.selectedProjectKey !== null;
29 const focusSearch = useCallback(() => {47 const focusSearch = useCallback(() => {
48 if (primaryColumn && !panes.primarySidebarVisible) {
49 focusAfterReveal.current = true;
50 togglePrimarySidebar();
51 return true;
52 }
30 searchBarRef.current?.focus();53 searchBarRef.current?.focus();
31 return searchBarRef.current !== null;54 return searchBarRef.current !== null;
32 }, []);55 }, [primaryColumn, panes.primarySidebarVisible, togglePrimarySidebar]);
33 useHardwareKeyboardCommand("focusSearch", focusSearch);56 useHardwareKeyboardCommand("focusSearch", focusSearch);
34 const filterMenu = buildHomeListFilterMenu(props);57 const filterMenu = buildHomeListFilterMenu(props);
3558
...@@ -42,7 +65,7 @@ export function HomeHeader(props: HomeHeaderProps) {...@@ -42,7 +65,7 @@ export function HomeHeader(props: HomeHeaderProps) {
42 // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.65 // (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
43 headerTintColor: iconColor,66 headerTintColor: iconColor,
44 unstable_headerRightItems: () =>67 unstable_headerRightItems: () =>
45 iPadSidebar68 sidebarHeader
46 ? createSidebarHeaderItems({69 ? createSidebarHeaderItems({
47 filterIcon: hasCustomListOptions70 filterIcon: hasCustomListOptions
48 ? "line.3.horizontal.decrease.circle.fill"71 ? "line.3.horizontal.decrease.circle.fill"
...@@ -62,7 +85,7 @@ export function HomeHeader(props: HomeHeaderProps) {...@@ -62,7 +85,7 @@ export function HomeHeader(props: HomeHeaderProps) {
62 ],85 ],
63 // The keys below are set per-branch (not `undefined`) so a later86 // The keys below are set per-branch (not `undefined`) so a later
64 // reapply cannot clobber options owned by NativeHeaderToolbar.87 // reapply cannot clobber options owned by NativeHeaderToolbar.
65 ...(iPadSidebar88 ...(sidebarHeader
66 ? {89 ? {
67 headerSearchBarOptions: {90 headerSearchBarOptions: {
68 ref: searchBarRef,91 ref: searchBarRef,
...@@ -78,7 +101,7 @@ export function HomeHeader(props: HomeHeaderProps) {...@@ -78,7 +101,7 @@ export function HomeHeader(props: HomeHeaderProps) {
78 },101 },
79 unstable_headerToolbarItems: () => [],102 unstable_headerToolbarItems: () => [],
80 }103 }
81 : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED104 : usesNativeMailSearchToolbar
82 ? {105 ? {
83 headerSearchBarOptions: {106 headerSearchBarOptions: {
84 ref: searchBarRef,107 ref: searchBarRef,
...@@ -121,7 +144,7 @@ export function HomeHeader(props: HomeHeaderProps) {...@@ -121,7 +144,7 @@ export function HomeHeader(props: HomeHeaderProps) {
121 }}144 }}
122 />145 />
123146
124 {iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (147 {sidebarHeader || usesNativeMailSearchToolbar ? null : (
125 <NativeHeaderToolbar placement="bottom">148 <NativeHeaderToolbar placement="bottom">
126 <NativeHeaderToolbar.Menu149 <NativeHeaderToolbar.Menu
127 accessibilityLabel="Filter threads"150 accessibilityLabel="Filter threads"
apps/mobile/src/features/home/HomeScreen.tsx+14-2
...@@ -36,8 +36,12 @@ import type { SavedRemoteConnection } from "../../lib/connection";...@@ -36,8 +36,12 @@ import type { SavedRemoteConnection } from "../../lib/connection";
36import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";36import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
37import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";37import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
38import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects";38import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects";
39import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";39import {
40 useNativeColumnLayoutMetrics,
41 useNativeLayoutMetrics,
42} from "../../native/native-layout-metrics";
40import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";43import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
44import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
41import { useThreadSearch } from "../../state/queries";45import { useThreadSearch } from "../../state/queries";
42import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";46import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
43import { usePendingThreadOrder } from "../../state/thread-order";47import { usePendingThreadOrder } from "../../state/thread-order";
...@@ -264,6 +268,7 @@ function renderHomeScrollView(props: ScrollViewProps) {...@@ -264,6 +268,7 @@ function renderHomeScrollView(props: ScrollViewProps) {
264/* ─── Main screen ────────────────────────────────────────────────────── */268/* ─── Main screen ────────────────────────────────────────────────────── */
265269
266export function HomeScreen(props: HomeScreenProps) {270export function HomeScreen(props: HomeScreenProps) {
271 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
267 const primaryColumn = use(NativePrimaryColumnContext);272 const primaryColumn = use(NativePrimaryColumnContext);
268 const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen";273 const contentBackground = primaryColumn ? "bg-drawer" : "bg-screen";
269 const containerClassName = cn(274 const containerClassName = cn(
...@@ -277,6 +282,10 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -277,6 +282,10 @@ export function HomeScreen(props: HomeScreenProps) {
277 const openSwipeableRef = useRef<SwipeableMethods | null>(null);282 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
278 const insets = useSafeAreaInsets();283 const insets = useSafeAreaInsets();
279 const { fabClearance } = useAndroidControlSizing();284 const { fabClearance } = useAndroidControlSizing();
285 const screenMetrics = useNativeLayoutMetrics();
286 const contentSideInsets = usesNativeWorkspaceColumns
287 ? (columnMetrics ?? screenMetrics)?.safeArea
288 : undefined;
280 // UIKit's column safe area already includes its bottom toolbar.289 // UIKit's column safe area already includes its bottom toolbar.
281 const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24);290 const iosBottomClearance = Math.max(columnMetrics?.safeArea.bottom ?? insets.bottom, 24);
282 const searchEnvironmentIds = useMemo(291 const searchEnvironmentIds = useMemo(
...@@ -908,6 +917,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -908,6 +917,8 @@ export function HomeScreen(props: HomeScreenProps) {
908 style={{917 style={{
909 paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24),918 paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24),
910 paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0,919 paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0,
920 paddingLeft: 32 + (contentSideInsets?.left ?? 0),
921 paddingRight: 32 + (contentSideInsets?.right ?? 0),
911 }}922 }}
912 >923 >
913 <View className="w-full max-w-[430px]">924 <View className="w-full max-w-[430px]">
...@@ -1038,7 +1049,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1038,7 +1049,8 @@ export function HomeScreen(props: HomeScreenProps) {
1038 {...scrollGateHandlers}1049 {...scrollGateHandlers}
1039 scrollEventThrottle={16}1050 scrollEventThrottle={16}
1040 contentContainerStyle={{1051 contentContainerStyle={{
1041 paddingHorizontal: primaryColumn ? 8 : 0,1052 paddingLeft: (contentSideInsets?.left ?? 0) + (primaryColumn ? 8 : 0),
1053 paddingRight: (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0),
1042 paddingBottom:1054 paddingBottom:
1043 Platform.OS === "ios"1055 Platform.OS === "ios"
1044 ? iosBottomClearance1056 ? iosBottomClearance
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+23-6
...@@ -66,6 +66,8 @@ import {...@@ -66,6 +66,8 @@ import {
66 NativeWorkspaceInspectorContext,66 NativeWorkspaceInspectorContext,
67} from "../../native/v5-workspace-context";67} from "../../native/v5-workspace-context";
6868
69import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
70
69interface AdaptiveWorkspaceContextValue {71interface AdaptiveWorkspaceContextValue {
70 readonly layout: Layout;72 readonly layout: Layout;
71 readonly panes: WorkspacePaneLayout;73 readonly panes: WorkspacePaneLayout;
...@@ -239,7 +241,9 @@ function AdaptiveWorkspaceLayoutContent(...@@ -239,7 +241,9 @@ function AdaptiveWorkspaceLayoutContent(
239) {241) {
240 const projectGroupingMode = props.projectGroupingMode;242 const projectGroupingMode = props.projectGroupingMode;
241 const nativeWorkspace = use(NativeWorkspaceModeContext);243 const nativeWorkspace = use(NativeWorkspaceModeContext);
242 const { width, height } = useWindowDimensions();244 const windowDimensions = useWindowDimensions();
245 const nativeMetrics = useNativeLayoutMetrics();
246 const { width, height } = nativeMetrics ?? windowDimensions;
243 const pathname = props.pathname;247 const pathname = props.pathname;
244 const navigation = useNavigation();248 const navigation = useNavigation();
245 const activeRoleOwner = useRef<symbol | null>(null);249 const activeRoleOwner = useRef<symbol | null>(null);
...@@ -256,7 +260,10 @@ function AdaptiveWorkspaceLayoutContent(...@@ -256,7 +260,10 @@ function AdaptiveWorkspaceLayoutContent(
256 const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState("");260 const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState("");
257 const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] =261 const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] =
258 useState<WorkspaceAuxiliaryPaneRole | null>(null);262 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 );
260 const layout = baseLayout;267 const layout = baseLayout;
261 // In split layouts the sidebar IS the thread list — it renders on every268 // In split layouts the sidebar IS the thread list — it renders on every
262 // route, including Home (which shows an empty-detail pane instead of the269 // route, including Home (which shows an empty-detail pane instead of the
...@@ -269,7 +276,9 @@ function AdaptiveWorkspaceLayoutContent(...@@ -269,7 +276,9 @@ function AdaptiveWorkspaceLayoutContent(
269 viewportWidth: width,276 viewportWidth: width,
270 preferredWidth: fileInspectorPreferredWidth ?? undefined,277 preferredWidth: fileInspectorPreferredWidth ?? undefined,
271 reservedLeadingWidth:278 reservedLeadingWidth:
272 shouldRenderPrimarySidebar && showPrimarySidebar ? (layout.listPaneWidth ?? 0) : 0,279 shouldRenderPrimarySidebar && showPrimarySidebar
280 ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0)
281 : 0,
273 }),282 }),
274 [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width],283 [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width],
275 );284 );
...@@ -470,13 +479,21 @@ function AdaptiveWorkspaceLayoutContent(...@@ -470,13 +479,21 @@ function AdaptiveWorkspaceLayoutContent(
470 );479 );
471480
472 const renderedSidebarWidth = useSharedValue(481 const renderedSidebarWidth = useSharedValue(
473 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0,482 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0,
474 );483 );
475 useEffect(() => {484 useEffect(() => {
476 if (nativeWorkspace) return;485 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;
478 renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);489 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 ]);
480 const sidebarAnimatedStyle = useAnimatedStyle(() => ({497 const sidebarAnimatedStyle = useAnimatedStyle(() => ({
481 opacity: Math.min(1, renderedSidebarWidth.value / 80),498 opacity: Math.min(1, renderedSidebarWidth.value / 80),
482 width: renderedSidebarWidth.value,499 width: renderedSidebarWidth.value,
apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx+59-9
...@@ -20,7 +20,7 @@ import {...@@ -20,7 +20,7 @@ import {
20 type NativeStackTypeBag,20 type NativeStackTypeBag,
21} from "@react-navigation/native-stack";21} from "@react-navigation/native-stack";
22import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react";22import { use, useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react";
23import { View } from "react-native";23import { Platform, View } from "react-native";
24import { Split, type SplitHostCommands } from "react-native-screens";24import { Split, type SplitHostCommands } from "react-native-screens";
25import { EnvironmentId, ThreadId } from "@t3tools/contracts";25import { EnvironmentId, ThreadId } from "@t3tools/contracts";
26import { scopedThreadKey } from "../../lib/scopedEntities";26import { scopedThreadKey } from "../../lib/scopedEntities";
...@@ -33,8 +33,13 @@ import {...@@ -33,8 +33,13 @@ import {
33 V5CardStackView,33 V5CardStackView,
34 V5StackView,34 V5StackView,
35} from "../../native/createV5StackNavigator.ios";35} from "../../native/createV5StackNavigator.ios";
36import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../../native/NativeWorkspaceColumns";36import {
37 useNativeWorkspaceColumnsReady,
38 useNativeWorkspaceColumnsSupported,
39} from "../../native/NativeWorkspaceColumns";
37import { V5StackHeader } from "../../native/V5StackHeader.ios";40import { V5StackHeader } from "../../native/V5StackHeader.ios";
41import type { AppNativeStackNavigationOptions } from "../../native/StackHeader";
42import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
38import {43import {
39 nativeWorkspacePopAction,44 nativeWorkspacePopAction,
40 projectWorkspaceStack,45 projectWorkspaceStack,
...@@ -89,7 +94,12 @@ function ColumnScreen(props: {...@@ -89,7 +94,12 @@ function ColumnScreen(props: {
89 canGoBack={!props.primary}94 canGoBack={!props.primary}
90 primary={props.primary}95 primary={props.primary}
91 />96 />
92 <ColumnContent primary={props.primary}>97 <ColumnContent
98 primary={props.primary}
99 insetHorizontally={
100 (descriptor.options as AppNativeStackNavigationOptions).nativeContentInsetHorizontally
101 }
102 >
93 <NativePrimaryColumnContext value={primaryColumn}>103 <NativePrimaryColumnContext value={primaryColumn}>
94 {descriptor.render()}104 {descriptor.render()}
95 </NativePrimaryColumnContext>105 </NativePrimaryColumnContext>
...@@ -107,6 +117,8 @@ function WorkspaceColumns(...@@ -107,6 +117,8 @@ function WorkspaceColumns(
107 const inspector = use(NativeWorkspaceInspectorContext);117 const inspector = use(NativeWorkspaceInspectorContext);
108 const hostRef = useRef<SplitHostCommands>(null);118 const hostRef = useRef<SplitHostCommands>(null);
109 const activeDetailKey = props.detail.at(-1)?.key;119 const activeDetailKey = props.detail.at(-1)?.key;
120 const compactColumn = activeDetailKey ? "secondary" : "primary";
121 const shownCompactColumn = useRef(compactColumn);
110 const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params;122 const threadParams = props.detail.findLast((route) => route.name === "Thread")?.params;
111 const selectedThreadKey =123 const selectedThreadKey =
112 threadParams &&124 threadParams &&
...@@ -131,9 +143,12 @@ function WorkspaceColumns(...@@ -131,9 +143,12 @@ function WorkspaceColumns(
131 useEffect(() => {143 useEffect(() => {
132 // In compact size classes UIKit exposes one column. Selecting a thread144 // In compact size classes UIKit exposes one column. Selecting a thread
133 // changes the visible column without rebuilding either navigation stack.145 // changes the visible column without rebuilding either navigation stack.
134 if (layout.usesSplitView) return;146 // The initial column is a native prop, so it is applied before the host
135 hostRef.current?.show(activeDetailKey ? "secondary" : "primary");147 // attaches. Commands only handle later navigation changes.
136 }, [activeDetailKey, layout.usesSplitView]);148 if (layout.usesSplitView || shownCompactColumn.current === compactColumn) return;
149 shownCompactColumn.current = compactColumn;
150 hostRef.current?.show(compactColumn);
151 }, [compactColumn, layout.usesSplitView]);
137152
138 const primary = props.descriptors[props.primary.key];153 const primary = props.descriptors[props.primary.key];
139 if (!primary) return null;154 if (!primary) return null;
...@@ -142,6 +157,7 @@ function WorkspaceColumns(...@@ -142,6 +157,7 @@ function WorkspaceColumns(
142 ref={hostRef}157 ref={hostRef}
143 testID="adaptive-workspace-layout"158 testID="adaptive-workspace-layout"
144 preferredSplitBehavior="tile"159 preferredSplitBehavior="tile"
160 topColumnForCollapsing={compactColumn}
145 preferredDisplayMode={161 preferredDisplayMode={
146 panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly"162 panes.primarySidebarVisible || !activeDetailKey ? "oneBesideSecondary" : "secondaryOnly"
147 }163 }
...@@ -153,7 +169,7 @@ function WorkspaceColumns(...@@ -153,7 +169,7 @@ function WorkspaceColumns(
153 }}169 }}
154 columnMetrics={{170 columnMetrics={{
155 minimumPrimaryColumnWidth: 280,171 minimumPrimaryColumnWidth: 280,
156 maximumPrimaryColumnWidth: 380,172 maximumPrimaryColumnWidth: Math.max(380, layout.listPaneWidth ?? 0),
157 preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320,173 preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320,
158 minimumSecondaryColumnWidth: 320,174 minimumSecondaryColumnWidth: 320,
159 }}175 }}
...@@ -179,6 +195,37 @@ function WorkspaceColumns(...@@ -179,6 +195,37 @@ function WorkspaceColumns(
179 options={{195 options={{
180 headerShown: true,196 headerShown: true,
181 title: "",197 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 : [],
182 unstable_headerLeftItems: () => [229 unstable_headerLeftItems: () => [
183 {230 {
184 type: "button",231 type: "button",
...@@ -293,6 +340,8 @@ function WorkspaceStackNavigator({...@@ -293,6 +340,8 @@ function WorkspaceStackNavigator({
293 UNSTABLE_router,340 UNSTABLE_router,
294 ...rest341 ...rest
295}: NativeStackNavigatorProps) {342}: NativeStackNavigatorProps) {
343 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
344 const nativeWorkspaceColumnsReady = useNativeWorkspaceColumnsReady();
296 const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<345 const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<
297 StackNavigationState<ParamListBase>,346 StackNavigationState<ParamListBase>,
298 StackRouterOptions,347 StackRouterOptions,
...@@ -310,9 +359,10 @@ function WorkspaceStackNavigator({...@@ -310,9 +359,10 @@ function WorkspaceStackNavigator({
310 screenLayout,359 screenLayout,
311 UNSTABLE_router,360 UNSTABLE_router,
312 });361 });
313 const WorkspaceView = NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? WorkspaceStackView : V5StackView;362 if (!nativeWorkspaceColumnsReady) return null;
363 const WorkspaceView = usesNativeWorkspaceColumns ? WorkspaceStackView : V5StackView;
314 return (364 return (
315 <NativeWorkspaceModeContext value={NATIVE_WORKSPACE_COLUMNS_SUPPORTED}>365 <NativeWorkspaceModeContext value={usesNativeWorkspaceColumns}>
316 <NavigationContent>366 <NavigationContent>
317 <WorkspaceView367 <WorkspaceView
318 {...rest}368 {...rest}
apps/mobile/src/features/layout/native-glass-header-items.ts+1
...@@ -4,6 +4,7 @@ type NativeGlassHeaderItem = {...@@ -4,6 +4,7 @@ type NativeGlassHeaderItem = {
4 readonly hidesSharedBackground?: boolean;4 readonly hidesSharedBackground?: boolean;
5 readonly sharesBackground?: boolean;5 readonly sharesBackground?: boolean;
6 readonly variant?: "plain" | "done" | "prominent";6 readonly variant?: "plain" | "done" | "prominent";
7 readonly axisBehavior?: "automatic" | "horizontalOnly" | "verticalPreferred";
7 readonly width?: number;8 readonly width?: number;
8};9};
910
apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx+2-2
...@@ -19,8 +19,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac...@@ -19,8 +19,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac
19import {19import {
20 createNativeMailSearchToolbarItem,20 createNativeMailSearchToolbarItem,
21 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,21 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
22 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
23} from "../layout/native-mail-search-toolbar";22} from "../layout/native-mail-search-toolbar";
23import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
2424
25import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses";25import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses";
2626
...@@ -72,7 +72,7 @@ export function SettingsOpenSourceLicensesRouteScreen() {...@@ -72,7 +72,7 @@ export function SettingsOpenSourceLicensesRouteScreen() {
72 const navigation = useNavigation();72 const navigation = useNavigation();
73 const insets = useSafeAreaInsets();73 const insets = useSafeAreaInsets();
74 const [query, setQuery] = useState("");74 const [query, setQuery] = useState("");
75 const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;75 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
76 const manifest = useMobileThirdPartyLicenses();76 const manifest = useMobileThirdPartyLicenses();
77 const entries = manifest?.entries ?? [];77 const entries = manifest?.entries ?? [];
78 const filteredEntries = useMemo(78 const filteredEntries = useMemo(
apps/mobile/src/features/settings/components/SettingsEnvironmentFilterHeader.tsx+2
...@@ -54,6 +54,7 @@ export function SettingsEnvironmentFilterHeader(props: {...@@ -54,6 +54,7 @@ export function SettingsEnvironmentFilterHeader(props: {
54 options={{54 options={{
55 unstable_headerRightItems: () => [55 unstable_headerRightItems: () => [
56 withNativeGlassHeaderItem({56 withNativeGlassHeaderItem({
57 axisBehavior: "horizontalOnly",
57 accessibilityLabel: "Filter settings environments and projects",58 accessibilityLabel: "Filter settings environments and projects",
58 icon: { name: filterIcon, type: "sfSymbol" },59 icon: { name: filterIcon, type: "sfSymbol" },
59 label: "",60 label: "",
...@@ -120,6 +121,7 @@ export function SettingsEnvironmentFilterHeader(props: {...@@ -120,6 +121,7 @@ export function SettingsEnvironmentFilterHeader(props: {
120 ...(closeSettings121 ...(closeSettings
121 ? [122 ? [
122 withNativeGlassHeaderItem({123 withNativeGlassHeaderItem({
124 axisBehavior: "horizontalOnly",
123 accessibilityLabel: "Close settings",125 accessibilityLabel: "Close settings",
124 icon: { name: "xmark", type: "sfSymbol" },126 icon: { name: "xmark", type: "sfSymbol" },
125 identifier: "settings-close",127 identifier: "settings-close",
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+2-2
...@@ -37,8 +37,8 @@ import { vcsEnvironment } from "../../state/vcs";...@@ -37,8 +37,8 @@ import { vcsEnvironment } from "../../state/vcs";
37import {37import {
38 createNativeMailSearchToolbarItem,38 createNativeMailSearchToolbarItem,
39 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,39 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
40 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
41} from "../layout/native-mail-search-toolbar";40} from "../layout/native-mail-search-toolbar";
41import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
42import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";42import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
43import { checkoutNewTaskBranch } from "./checkout-new-task-branch";43import { checkoutNewTaskBranch } from "./checkout-new-task-branch";
4444
...@@ -413,7 +413,7 @@ export function BranchPickerScreen(props: {...@@ -413,7 +413,7 @@ export function BranchPickerScreen(props: {
413}) {413}) {
414 const navigation = useNavigation();414 const navigation = useNavigation();
415 const insets = useSafeAreaInsets();415 const insets = useSafeAreaInsets();
416 const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;416 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
417 const selectedBranchName =417 const selectedBranchName =
418 props.selectedBranchName ??418 props.selectedBranchName ??
419 props.branches.find((branch) => branch.current)?.name ??419 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...@@ -14,6 +14,8 @@ import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp
14import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";14import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
15import type { LegendListRef } from "@legendapp/list/react-native";15import type { LegendListRef } from "@legendapp/list/react-native";
16import { HeaderHeightContext } from "@react-navigation/elements";16import { HeaderHeightContext } from "@react-navigation/elements";
17import { NativeLayoutObserver } from "../../native/NativeLayoutObserver";
18import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/reserved-regions";
17import type {19import type {
18 EnvironmentId,20 EnvironmentId,
19 MessageId,21 MessageId,
...@@ -84,6 +86,8 @@ import Animated, {...@@ -84,6 +86,8 @@ import Animated, {
84} from "react-native-reanimated";86} from "react-native-reanimated";
85import { useSafeAreaInsets } from "react-native-safe-area-context";87import { useSafeAreaInsets } from "react-native-safe-area-context";
86import { useWorkspaceContentWidth } from "../layout/workspace-content-width";88import { useWorkspaceContentWidth } from "../layout/workspace-content-width";
89import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
90import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
87import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";91import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
88import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";92import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
89import type { ComposerEditorHandle } from "../../components/ComposerEditor";93import type { ComposerEditorHandle } from "../../components/ComposerEditor";
...@@ -317,6 +321,7 @@ const USER_INPUT_TOGGLE_TIMING = {...@@ -317,6 +321,7 @@ const USER_INPUT_TOGGLE_TIMING = {
317};321};
318322
319export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {323export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
324 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
320 const navigation = useNavigation();325 const navigation = useNavigation();
321 const { session: voiceInputSession } = useGlobalVoiceInput();326 const { session: voiceInputSession } = useGlobalVoiceInput();
322 const reportedModelSelection = useThreadReportedModelSelection({327 const reportedModelSelection = useThreadReportedModelSelection({
...@@ -388,7 +393,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -388,7 +393,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
388 }393 }
389 }, []);394 }, []);
390 const windowHeight = useWindowDimensions().height;395 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);
392 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;401 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
393 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);402 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
394 const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null;403 const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null;
...@@ -826,8 +835,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -826,8 +835,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
826 // Assign both layouts explicitly so the dock always follows its current parent.835 // Assign both layouts explicitly so the dock always follows its current parent.
827 const composerWidthStyle = useAnimatedStyle(() =>836 const composerWidthStyle = useAnimatedStyle(() =>
828 isSplitLayout && workspaceContentWidth !== null837 isSplitLayout && workspaceContentWidth !== null
829 ? { width: workspaceContentWidth.value }838 ? {
830 : { width: "100%" },839 width: Math.max(
840 0,
841 workspaceContentWidth.value - controlInsets.left - controlInsets.right,
842 ),
843 right: undefined,
844 }
845 : { width: undefined, right: controlInsets.right },
831 );846 );
832 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;847 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
833 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);848 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
...@@ -1083,6 +1098,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1083,6 +1098,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
10831098
1084 return (1099 return (
1085 <View className="flex-1">1100 <View className="flex-1">
1101 {!usesNativeWorkspaceColumns ? <NativeLayoutObserver onChange={setScreenMetrics} /> : null}
1086 {showContent ? (1102 {showContent ? (
1087 <View1103 <View
1088 style={{ flex: 1 }}1104 style={{ flex: 1 }}
...@@ -1140,6 +1156,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1140,6 +1156,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1140 }1156 }
1141 contentMaxWidth={contentMaxWidth}1157 contentMaxWidth={contentMaxWidth}
1142 historyControls={props.historyControls}1158 historyControls={props.historyControls}
1159 contentSideInsets={nativeMetrics?.safeArea}
1143 layoutVariant={layoutVariant}1160 layoutVariant={layoutVariant}
1144 usesAutomaticContentInsets={props.usesAutomaticContentInsets}1161 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
1145 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}1162 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
...@@ -1183,7 +1200,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1183,7 +1200,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1183 <Animated.View1200 <Animated.View
1184 layout={COMPOSER_LAYOUT_TRANSITION}1201 layout={COMPOSER_LAYOUT_TRANSITION}
1185 pointerEvents="box-none"1202 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 ]}
1187 >1212 >
1188 {/* No paddingTop here: the overlay's measured height becomes the1213 {/* No paddingTop here: the overlay's measured height becomes the
1189 list's bottom inset, so any padding above the pill/composer1214 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";...@@ -57,6 +57,7 @@ import { videoMimeType } from "@t3tools/shared/video";
57import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";57import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
58import { HeaderHeightContext } from "@react-navigation/elements";58import { HeaderHeightContext } from "@react-navigation/elements";
59import { useFocusEffect, useNavigation } from "@react-navigation/native";59import { useFocusEffect, useNavigation } from "@react-navigation/native";
60import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
60import {61import {
61 createContext,62 createContext,
62 memo,63 memo,
...@@ -297,6 +298,7 @@ export interface ThreadFeedProps {...@@ -297,6 +298,7 @@ export interface ThreadFeedProps {
297 readonly contentBottomInset?: number;298 readonly contentBottomInset?: number;
298 readonly historyControls?: ThreadFeedHistoryControls;299 readonly historyControls?: ThreadFeedHistoryControls;
299 readonly contentMaxWidth?: number;300 readonly contentMaxWidth?: number;
301 readonly contentSideInsets?: { readonly left: number; readonly right: number };
300 readonly layoutVariant?: LayoutVariant;302 readonly layoutVariant?: LayoutVariant;
301 readonly usesAutomaticContentInsets?: boolean;303 readonly usesAutomaticContentInsets?: boolean;
302 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;304 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
...@@ -1543,6 +1545,7 @@ function renderFeedEntry(...@@ -1543,6 +1545,7 @@ function renderFeedEntry(
1543 readonly reviewCommentColors: ReviewCommentColors;1545 readonly reviewCommentColors: ReviewCommentColors;
1544 readonly reviewCommentBubbleWidth: number;1546 readonly reviewCommentBubbleWidth: number;
1545 readonly themeAppearance: "light" | "dark";1547 readonly themeAppearance: "light" | "dark";
1548 readonly usesNativeWorkspaceColumns: boolean;
1546 readonly userBubbleMaxWidth: number;1549 readonly userBubbleMaxWidth: number;
1547 /** Width assistant markdown lays out in, so images can size their frame before layout. */1550 /** Width assistant markdown lays out in, so images can size their frame before layout. */
1548 readonly markdownContentWidth: number;1551 readonly markdownContentWidth: number;
...@@ -1730,11 +1733,19 @@ function renderFeedEntry(...@@ -1730,11 +1733,19 @@ function renderFeedEntry(
1730 className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"1733 className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"
1731 style={{1734 style={{
1732 backgroundColor: userBubbleColor,1735 backgroundColor: userBubbleColor,
1733 maxWidth: props.userBubbleMaxWidth,1736 maxWidth:
1737 props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad
1738 ? "85%"
1739 : props.userBubbleMaxWidth,
1734 ...(hasReviewCommentContext1740 ...(hasReviewCommentContext
1735 ? { width: props.reviewCommentBubbleWidth }1741 ? { width: props.reviewCommentBubbleWidth }
1736 : hasWideBlock1742 : hasWideBlock
1737 ? { width: props.userBubbleMaxWidth }1743 ? {
1744 width:
1745 props.usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad
1746 ? "85%"
1747 : props.userBubbleMaxWidth,
1748 }
1738 : null),1749 : null),
1739 }}1750 }}
1740 >1751 >
...@@ -2174,6 +2185,7 @@ function ThreadFeedPlaceholder(props: {...@@ -2174,6 +2185,7 @@ function ThreadFeedPlaceholder(props: {
2174}2185}
21752186
2176export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {2187export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2188 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
2177 const navigation = useNavigation();2189 const navigation = useNavigation();
2178 const { themeAppearance } = useAppearancePreferences();2190 const { themeAppearance } = useAppearancePreferences();
2179 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);2191 const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
...@@ -2257,12 +2269,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2257,12 +2269,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2257 setExpandedFile(null);2269 setExpandedFile(null);
2258 }, [props.environmentId, props.threadId, props.contentPresentation.kind]);2270 }, [props.environmentId, props.threadId, props.contentPresentation.kind]);
2259 const horizontalPadding = props.layoutVariant === "split" ? 20 : 16;2271 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);
2260 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({2275 const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({
2261 viewportWidth,2276 viewportWidth: usableViewportWidth,
2262 maxContentWidth: props.contentMaxWidth ?? null,2277 maxContentWidth: props.contentMaxWidth ?? null,
2263 minimumPadding: horizontalPadding,2278 minimumPadding: horizontalPadding,
2264 });2279 });
2265 const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2);2280 const contentWidth = Math.max(0, usableViewportWidth - contentHorizontalPadding * 2);
2266 const userBubbleMaxWidth = contentWidth * 0.85;2281 const userBubbleMaxWidth = contentWidth * 0.85;
2267 const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2);2282 const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2);
2268 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);2283 const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth);
...@@ -2285,7 +2300,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2285,7 +2300,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2285 // header-providing screen) and fall back to the standard iOS bar height.2300 // header-providing screen) and fall back to the standard iOS bar height.
2286 const navigationHeaderHeight = useContext(HeaderHeightContext);2301 const navigationHeaderHeight = useContext(HeaderHeightContext);
2287 const anchorTopInset = usesNativeAutomaticInsets2302 const anchorTopInset = usesNativeAutomaticInsets
2288 ? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT2303 ? (navigationHeaderHeight ?? insets.top + IOS_NAV_BAR_HEIGHT)
2289 : topContentInset;2304 : topContentInset;
22902305
2291 const theme = useUniwindTheme();2306 const theme = useUniwindTheme();
...@@ -3030,6 +3045,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -3030,6 +3045,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
3030 reviewCommentColors,3045 reviewCommentColors,
3031 reviewCommentBubbleWidth,3046 reviewCommentBubbleWidth,
3032 themeAppearance,3047 themeAppearance,
3048 usesNativeWorkspaceColumns,
3033 userBubbleMaxWidth,3049 userBubbleMaxWidth,
3034 markdownContentWidth,3050 markdownContentWidth,
3035 contentWidth,3051 contentWidth,
...@@ -3067,6 +3083,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -3067,6 +3083,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
3067 reviewCommentColors,3083 reviewCommentColors,
3068 reviewCommentBubbleWidth,3084 reviewCommentBubbleWidth,
3069 themeAppearance,3085 themeAppearance,
3086 usesNativeWorkspaceColumns,
3070 userBubbleMaxWidth,3087 userBubbleMaxWidth,
3071 markdownContentWidth,3088 markdownContentWidth,
3072 contentWidth,3089 contentWidth,
...@@ -3119,7 +3136,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -3119,7 +3136,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
3119 // (facebook/react-native#54123); the anchored end space after a send3136 // (facebook/react-native#54123); the anchored end space after a send
3120 // is pure inset, so without this the blank region can't be scrolled.3137 // is pure inset, so without this the blank region can't be scrolled.
3121 applyWorkaroundForContentInsetHitTestBug3138 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 }
3123 automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets}3151 automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets}
3124 {...(usesNativeAutomaticInsets3152 {...(usesNativeAutomaticInsets
3125 ? {3153 ? {
...@@ -3231,7 +3259,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -3231,7 +3259,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
3231 }3259 }
3232 contentContainerStyle={{3260 contentContainerStyle={{
3233 paddingTop: 12,3261 paddingTop: 12,
3234 paddingHorizontal: contentHorizontalPadding,3262 paddingLeft: contentHorizontalPadding + contentLeftInset,
3263 paddingRight: contentHorizontalPadding + contentRightInset,
3235 }}3264 }}
3236 />3265 />
3237 </View>3266 </View>
...@@ -3239,7 +3268,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -3239,7 +3268,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
3239 !props.worktreeSetup &&3268 !props.worktreeSetup &&
3240 props.activeWorkStartedAt === null &&3269 props.activeWorkStartedAt === null &&
3241 props.contentPresentation.kind === "ready" ? (3270 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 >
3243 <ThreadFeedPlaceholder3275 <ThreadFeedPlaceholder
3244 title="No conversation yet"3276 title="No conversation yet"
3245 detail="Ask the agent to inspect the repo, run a command, or continue the active thread."3277 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 {...@@ -73,8 +73,8 @@ import {
73import {73import {
74 createNativeMailSearchToolbarItem,74 createNativeMailSearchToolbarItem,
75 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,75 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
76 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
77} from "../layout/native-mail-search-toolbar";76} from "../layout/native-mail-search-toolbar";
77import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
78import { ModelRow, ChoiceRow } from "./ThreadSettingsRows";78import { ModelRow, ChoiceRow } from "./ThreadSettingsRows";
79import {79import {
80 compatibleRuntimeModeForChoices,80 compatibleRuntimeModeForChoices,
...@@ -742,10 +742,10 @@ function ThreadSettingsOptionsItem(props: {...@@ -742,10 +742,10 @@ function ThreadSettingsOptionsItem(props: {
742 .get(session.environmentId)742 .get(session.environmentId)
743 ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null)743 ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null)
744 : null;744 : null;
745 const bottomToolbarInset =745 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
746 Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED746 const bottomToolbarInset = usesNativeMailSearchToolbar
747 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET747 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET
748 : 0;748 : 0;
749749
750 return (750 return (
751 <View style={{ paddingBottom: insets.bottom + bottomToolbarInset + 12 }}>751 <View style={{ paddingBottom: insets.bottom + bottomToolbarInset + 12 }}>
...@@ -1107,7 +1107,7 @@ function ThreadSettingsModelsScreen() {...@@ -1107,7 +1107,7 @@ function ThreadSettingsModelsScreen() {
1107 const session = useThreadSettingsSession();1107 const session = useThreadSettingsSession();
1108 const presentation = useThreadSettingsPickerPresentation();1108 const presentation = useThreadSettingsPickerPresentation();
1109 const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>();1109 const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>();
1110 const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;1110 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
1111 const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy;1111 const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy;
1112 const commitAndClose = useCallback(() => {1112 const commitAndClose = useCallback(() => {
1113 if (!session.commitPendingModel()) return;1113 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...@@ -21,6 +21,7 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re
2121
22import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";22import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
23import { useSafeAreaInsets } from "react-native-safe-area-context";23import { useSafeAreaInsets } from "react-native-safe-area-context";
24import { NativeSheetContent } from "../../../native/NativeSheetContent";
24import { useUniwindTheme } from "../../../lib/useUniwindTheme";25import { useUniwindTheme } from "../../../lib/useUniwindTheme";
25import {26import {
26 AndroidHeaderIconButton,27 AndroidHeaderIconButton,
...@@ -411,7 +412,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {...@@ -411,7 +412,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
411 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}412 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
412 style={{ backgroundColor: sheetColor, flex: 1 }}413 style={{ backgroundColor: sheetColor, flex: 1 }}
413 >414 >
414 {content}415 <NativeSheetContent>{content}</NativeSheetContent>
415 <ScreenStackHeaderConfig416 <ScreenStackHeaderConfig
416 backgroundColor="rgba(0,0,0,0)"417 backgroundColor="rgba(0,0,0,0)"
417 color={foregroundColor}418 color={foregroundColor}
...@@ -447,7 +448,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {...@@ -447,7 +448,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
447 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}448 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
448 style={{ backgroundColor: sheetColor, flex: 1 }}449 style={{ backgroundColor: sheetColor, flex: 1 }}
449 >450 >
450 {content}451 <NativeSheetContent>{content}</NativeSheetContent>
451 <ScreenStackHeaderConfig452 <ScreenStackHeaderConfig
452 backgroundColor="rgba(0,0,0,0)"453 backgroundColor="rgba(0,0,0,0)"
453 color={foregroundColor}454 color={foregroundColor}
apps/mobile/src/features/threads/sidebar-native-header-items.ts+5-2
...@@ -34,7 +34,8 @@ function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHead...@@ -34,7 +34,8 @@ function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHead
34/**34/**
35 * Right-side UINavigationBar items for the sidebar column: the thread list35 * Right-side UINavigationBar items for the sidebar column: the thread list
36 * filter/sort menu plus the settings button, sharing one glass capsule —36 * 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.
38 */39 */
39export function createSidebarHeaderItems(input: {40export function createSidebarHeaderItems(input: {
40 readonly filterIcon: string;41 readonly filterIcon: string;
...@@ -44,6 +45,7 @@ export function createSidebarHeaderItems(input: {...@@ -44,6 +45,7 @@ export function createSidebarHeaderItems(input: {
44 return [45 return [
45 withNativeGlassHeaderItem({46 withNativeGlassHeaderItem({
46 type: "menu",47 type: "menu",
48 axisBehavior: "horizontalOnly",
47 label: "",49 label: "",
48 accessibilityLabel: "Filter threads",50 accessibilityLabel: "Filter threads",
49 icon: sfSymbolIcon(input.filterIcon),51 icon: sfSymbolIcon(input.filterIcon),
...@@ -54,7 +56,8 @@ export function createSidebarHeaderItems(input: {...@@ -54,7 +56,8 @@ export function createSidebarHeaderItems(input: {
54 }),56 }),
55 withNativeGlassHeaderItem({57 withNativeGlassHeaderItem({
56 type: "button",58 type: "button",
57 label: "",59 axisBehavior: "horizontalOnly",
60 label: "Settings",
58 accessibilityLabel: "Open settings",61 accessibilityLabel: "Open settings",
59 icon: sfSymbolIcon("gearshape"),62 icon: sfSymbolIcon("gearshape"),
60 onPress: input.onOpenSettings,63 onPress: input.onOpenSettings,
apps/mobile/src/features/threads/useThreadHeaderOptions.tsx+83-16
...@@ -1,8 +1,11 @@...@@ -1,8 +1,11 @@
1import { StackActions, useNavigation } from "@react-navigation/native";1import { StackActions, useNavigation } from "@react-navigation/native";
2import { useMemo } from "react";2import { useMemo } from "react";
3import { Platform } from "react-native";
3import type { AppNativeStackNavigationOptions } from "../../native/StackHeader";4import type { AppNativeStackNavigationOptions } from "../../native/StackHeader";
5import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
4import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";6import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
5import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";7import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
8import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
6import {9import {
7 ThreadGitControls,10 ThreadGitControls,
8 useThreadGitCenterHeaderItems,11 useThreadGitCenterHeaderItems,
...@@ -19,18 +22,24 @@ export function useThreadHeaderOptions(props: {...@@ -19,18 +22,24 @@ export function useThreadHeaderOptions(props: {
19 readonly gitControls: Parameters<typeof ThreadGitControls>[0];22 readonly gitControls: Parameters<typeof ThreadGitControls>[0];
20 readonly onReturnToThread?: () => void;23 readonly onReturnToThread?: () => void;
21}) {24}) {
25 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
26 const usesDuoHeader = usesNativeWorkspaceColumns && Platform.OS === "ios" && !Platform.isPad;
22 const navigation = useNavigation();27 const navigation = useNavigation();
23 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();28 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
24 const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls);29 const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls);
25 const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls);30 const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls);
26 const splitLeftHeaderItems = useMemo<NativeHeaderItems>(31 const splitLeftHeaderItems = useMemo<NativeHeaderItems>(
27 () => [32 () => [
28 {33 ...(!usesDuoHeader
29 // Match Mail's split-view detail toolbar: the first detail action sits34 ? [
30 // inside the content pane, not flush against the sidebar divider.35 {
31 spacing: 18,36 // Match Mail's split-view detail toolbar: the first detail action sits
32 type: "spacing" as const,37 // inside the content pane, not flush against the sidebar divider.
33 },38 spacing: 18,
39 type: "spacing" as const,
40 },
41 ]
42 : []),
34 ...(props.onReturnToThread43 ...(props.onReturnToThread
35 ? [44 ? [
36 withNativeGlassHeaderItem({45 withNativeGlassHeaderItem({
...@@ -43,6 +52,7 @@ export function useThreadHeaderOptions(props: {...@@ -43,6 +52,7 @@ export function useThreadHeaderOptions(props: {
43 ]52 ]
44 : []),53 : []),
45 withNativeGlassHeaderItem({54 withNativeGlassHeaderItem({
55 axisBehavior: usesNativeWorkspaceColumns ? "horizontalOnly" : undefined,
46 accessibilityLabel: panes.primarySidebarVisible56 accessibilityLabel: panes.primarySidebarVisible
47 ? "Maximize content"57 ? "Maximize content"
48 : "Show thread sidebar",58 : "Show thread sidebar",
...@@ -51,18 +61,31 @@ export function useThreadHeaderOptions(props: {...@@ -51,18 +61,31 @@ export function useThreadHeaderOptions(props: {
51 type: "sfSymbol" as const,61 type: "sfSymbol" as const,
52 },62 },
53 identifier: "thread-left-sidebar",63 identifier: "thread-left-sidebar",
64 label: panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar",
54 onPress: togglePrimarySidebar,65 onPress: togglePrimarySidebar,
55 type: "button" as const,66 type: "button" as const,
56 }),67 }),
57 withNativeGlassHeaderItem({68 ...(!usesDuoHeader
58 accessibilityLabel: "New task",69 ? [
59 icon: { name: "square.and.pencil", type: "sfSymbol" as const },70 withNativeGlassHeaderItem({
60 identifier: "thread-left-new-task",71 accessibilityLabel: "New task",
61 onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),72 icon: { name: "square.and.pencil", type: "sfSymbol" as const },
62 type: "button" as const,73 identifier: "thread-left-new-task",
63 }),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,
64 ],88 ],
65 [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
66 );89 );
67 // Deep links / cold starts land with Thread as the ONLY route, where the90 // Deep links / cold starts land with Thread as the ONLY route, where the
68 // native back button does not render. Provide an explicit Home escape for91 // native back button does not render. Provide an explicit Home escape for
...@@ -81,6 +104,41 @@ export function useThreadHeaderOptions(props: {...@@ -81,6 +104,41 @@ export function useThreadHeaderOptions(props: {
81 [navigation],104 [navigation],
82 );105 );
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
84 const options: AppNativeStackNavigationOptions = {142 const options: AppNativeStackNavigationOptions = {
85 headerShown: true,143 headerShown: true,
86 headerTitle: props.title,144 headerTitle: props.title,
...@@ -104,7 +162,12 @@ export function useThreadHeaderOptions(props: {...@@ -104,7 +162,12 @@ export function useThreadHeaderOptions(props: {
104 // the git controls on the RIGHT (no center items — center space is162 // the git controls on the RIGHT (no center items — center space is
105 // reserved for future breadcrumbs/status).163 // reserved for future breadcrumbs/status).
106 unstable_headerRightItems: () =>164 unstable_headerRightItems: () =>
107 layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems,165 usesDuoHeader
166 ? duoRightHeaderItems
167 : layout.usesSplitView
168 ? threadCenterHeaderItems
169 : compactRightHeaderItems,
170 unstable_headerToolbarItems: () => [],
108 unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined,171 unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined,
109 contentStyle: undefined,172 contentStyle: undefined,
110 };173 };
...@@ -116,7 +179,11 @@ export function useThreadHeaderOptions(props: {...@@ -116,7 +179,11 @@ export function useThreadHeaderOptions(props: {
116 // callbacks also read state the items do not display (a "Push" item runs `push` or179 // callbacks also read state the items do not display (a "Push" item runs `push` or
117 // `commit_push` depending on the default ref), so that state is keyed too.180 // `commit_push` depending on the default ref), so that state is keyed too.
118 optionsVersion: [181 optionsVersion: [
119 layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems,182 splitLeftHeaderItems,
183 threadCenterHeaderItems,
184 compactRightHeaderItems,
185 duoRightHeaderItems,
186 usesDuoHeader,
120 environmentId,187 environmentId,
121 threadId,188 threadId,
122 gitStatus?.isDefaultRef,189 gitStatus?.isDefaultRef,
apps/mobile/src/lib/layout.ts+37-10
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { scaledTypographyLineHeight } from "./appearancePreferences";1import { scaledTypographyLineHeight } from "./appearancePreferences";
2import { MOBILE_TYPOGRAPHY } from "./typography";2import { MOBILE_TYPOGRAPHY } from "./typography";
3import type { NativeLayoutMetrics } from "./reserved-regions";
34
4function clamp(value: number, min: number, max: number): number {5function clamp(value: number, min: number, max: number): number {
5 return Math.min(Math.max(value, min), max);6 return Math.min(Math.max(value, min), max);
...@@ -56,6 +57,7 @@ export interface Layout {...@@ -56,6 +57,7 @@ export interface Layout {
56 readonly variant: LayoutVariant;57 readonly variant: LayoutVariant;
57 readonly usesSplitView: boolean;58 readonly usesSplitView: boolean;
58 readonly listPaneWidth: number | null;59 readonly listPaneWidth: number | null;
60 readonly listPaneGap?: number;
59 readonly shellPadding: number;61 readonly shellPadding: number;
60}62}
6163
...@@ -87,9 +89,25 @@ export function deriveThreadFeedInitialContentInset(input: {...@@ -87,9 +89,25 @@ export function deriveThreadFeedInitialContentInset(input: {
8789
88export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector";90export 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 {
91 const { width, height } = input;97 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
94 if (!wideEnoughForSplit) {112 if (!wideEnoughForSplit) {
95 return {113 return {
...@@ -100,14 +118,20 @@ export function deriveLayout(input: { readonly width: number; readonly height: n...@@ -100,14 +118,20 @@ export function deriveLayout(input: { readonly width: number; readonly height: n
100 };118 };
101 }119 }
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 );
103 return {128 return {
104 variant: "split",129 variant: "split",
105 usesSplitView: true,130 usesSplitView: true,
106 listPaneWidth: clamp(131 listPaneWidth:
107 Math.round(width * 0.32),132 division?.x ??
108 SPLIT_SIDEBAR_MIN_WIDTH,133 clamp(Math.round(width * 0.32), SPLIT_SIDEBAR_MIN_WIDTH, SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH),
109 SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH,134 ...(division ? { listPaneGap: division.width } : {}),
110 ),
111 shellPadding: 0,135 shellPadding: 0,
112 };136 };
113}137}
...@@ -125,7 +149,7 @@ export function deriveWorkspacePaneLayout(input: {...@@ -125,7 +149,7 @@ export function deriveWorkspacePaneLayout(input: {
125 const preferredPrimarySidebarVisible =149 const preferredPrimarySidebarVisible =
126 input.layout.usesSplitView && input.primarySidebarPreferredVisible;150 input.layout.usesSplitView && input.primarySidebarPreferredVisible;
127 const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible151 const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible
128 ? (input.layout.listPaneWidth ?? 0)152 ? (input.layout.listPaneWidth ?? 0) + (input.layout.listPaneGap ?? 0)
129 : 0;153 : 0;
130154
131 if (auxiliaryPaneRole === "inspector") {155 if (auxiliaryPaneRole === "inspector") {
...@@ -141,11 +165,14 @@ export function deriveWorkspacePaneLayout(input: {...@@ -141,11 +165,14 @@ export function deriveWorkspacePaneLayout(input: {
141 auxiliaryPaneVisible &&165 auxiliaryPaneVisible &&
142 fileInspector.width !== null &&166 fileInspector.width !== null &&
143 input.layout.listPaneWidth !== null &&167 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 <
145 FILE_INSPECTOR_MIN_MAIN_WIDTH;172 FILE_INSPECTOR_MIN_MAIN_WIDTH;
146 const primarySidebarVisible =173 const primarySidebarVisible =
147 preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary;174 preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary;
148 const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0;175 const primarySidebarWidth = primarySidebarVisible ? preferredPrimarySidebarWidth : 0;
149176
150 return {177 return {
151 primarySidebarVisible,178 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,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
2export interface NativeLayoutMetrics {9export interface NativeLayoutMetrics {
3 readonly width: number;10 readonly width: number;
4 readonly height: number;11 readonly height: number;
12 readonly hasHinge?: boolean;
5 readonly horizontalSizeClass: "compact" | "regular";13 readonly horizontalSizeClass: "compact" | "regular";
14 readonly verticalBarEdge: "none" | "left" | "right";
6 readonly safeArea: {15 readonly safeArea: {
7 readonly top: number;16 readonly top: number;
8 readonly bottom: number;17 readonly bottom: number;
9 readonly left: number;18 readonly left: number;
10 readonly right: number;19 readonly right: number;
11 };20 };
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 };
12}51}
apps/mobile/src/native/NativeColumnContent.ios.tsx+5-1
...@@ -9,12 +9,16 @@ import { NativeLayoutObserver } from "./NativeLayoutObserver";...@@ -9,12 +9,16 @@ import { NativeLayoutObserver } from "./NativeLayoutObserver";
9export function NativeColumnContent(props: {9export function NativeColumnContent(props: {
10 readonly children: ReactNode;10 readonly children: ReactNode;
11 readonly primary?: boolean;11 readonly primary?: boolean;
12 readonly insetHorizontally?: boolean;
12}) {13}) {
13 const [metrics, setMetrics] = useState<NativeLayoutMetrics | null>(null);14 const [metrics, setMetrics] = useState<NativeLayoutMetrics | null>(null);
14 return (15 return (
15 <View className={props.primary ? "flex-1 bg-drawer" : "flex-1 bg-screen"}>16 <View className={props.primary ? "flex-1 bg-drawer" : "flex-1 bg-screen"}>
16 <SafeAreaProvider style={{ flex: 1 }}>17 <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 >
18 {/* Observe the inset body. UIKit can reserve other columns in its safe area. */}22 {/* Observe the inset body. UIKit can reserve other columns in its safe area. */}
19 <View style={{ flex: 1 }}>23 <View style={{ flex: 1 }}>
20 <NativeLayoutObserver onChange={setMetrics} />24 <NativeLayoutObserver onChange={setMetrics} />
apps/mobile/src/native/NativeHeaderToolbar.tsx+25-7
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 type ReactNode,14 type ReactNode,
15} from "react";15} from "react";
16import type { ColorValue } from "react-native";16import type { ColorValue } from "react-native";
17import type { HeaderBarButtonSearchBarPlacementItem } from "react-native-screens";
1718
18function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null {19function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null {
19 return useNavigation<NativeStackNavigationProp<ParamListBase>>();20 return useNavigation<NativeStackNavigationProp<ParamListBase>>();
...@@ -34,8 +35,13 @@ function labelFromChildren(children: ReactNode): string {...@@ -34,8 +35,13 @@ function labelFromChildren(children: ReactNode): string {
34type NativeStackHeaderIcon = NonNullable<35type NativeStackHeaderIcon = NonNullable<
35 Extract<NativeStackHeaderItem, { type: "button" }>["icon"]36 Extract<NativeStackHeaderItem, { type: "button" }>["icon"]
36>;37>;
38type NativeStackHeaderAxisBehavior = Extract<
39 NativeStackHeaderItem,
40 { type: "button" }
41>["axisBehavior"];
42type NativeToolbarItem = NativeStackHeaderItem | HeaderBarButtonSearchBarPlacementItem;
37type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {43type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {
38 unstable_headerToolbarItems?: () => NativeStackHeaderItem[];44 unstable_headerToolbarItems?: () => NativeToolbarItem[];
39};45};
4046
41function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {47function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {
...@@ -112,7 +118,7 @@ function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"...@@ -112,7 +118,7 @@ function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"
112 return items;118 return items;
113}119}
114120
115function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {121function convertToolbarChild(child: ReactNode): NativeToolbarItem | null {
116 if (!isValidElement<ToolbarElementProps>(child)) {122 if (!isValidElement<ToolbarElementProps>(child)) {
117 return null;123 return null;
118 }124 }
...@@ -121,15 +127,22 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {...@@ -121,15 +127,22 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
121 if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) {127 if (typeName === "NativeHeaderToolbarCustom" && isValidElement(child.props.children)) {
122 return { type: "custom", element: child.props.children };128 return { type: "custom", element: child.props.children };
123 }129 }
130 if (typeName === "NativeHeaderToolbarSearchBarSlot") {
131 return { type: "searchBarPlacement" };
132 }
124133
125 if (typeName === "NativeHeaderToolbarButton") {134 if (typeName === "NativeHeaderToolbarButton") {
126 return {135 return {
127 type: "button",136 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 ?? ""),
129 accessibilityLabel:141 accessibilityLabel:
130 typeof child.props.accessibilityLabel === "string"142 typeof child.props.accessibilityLabel === "string"
131 ? child.props.accessibilityLabel143 ? child.props.accessibilityLabel
132 : undefined,144 : undefined,
145 axisBehavior: child.props.axisBehavior as NativeStackHeaderAxisBehavior,
133 disabled: Boolean(child.props.disabled),146 disabled: Boolean(child.props.disabled),
134 icon: iconFromProp(child.props.icon),147 icon: iconFromProp(child.props.icon),
135 onPress:148 onPress:
...@@ -173,8 +186,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {...@@ -173,8 +186,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
173 return null;186 return null;
174}187}
175188
176function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] {189function collectToolbarItems(children: ReactNode): NativeToolbarItem[] {
177 const items: NativeStackHeaderItem[] = [];190 const items: NativeToolbarItem[] = [];
178 Children.forEach(children, (child) => {191 Children.forEach(children, (child) => {
179 const item = convertToolbarChild(child);192 const item = convertToolbarChild(child);
180 if (item) {193 if (item) {
...@@ -211,13 +224,17 @@ function NativeHeaderToolbarRoot(props: {...@@ -211,13 +224,17 @@ function NativeHeaderToolbarRoot(props: {
211 } as NativeStackOptionsWithToolbar);224 } as NativeStackOptionsWithToolbar);
212 };225 };
213 }226 }
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 );
214 if (props.placement === "left") {231 if (props.placement === "left") {
215 navigation.setOptions({ unstable_headerLeftItems: () => items });232 navigation.setOptions({ unstable_headerLeftItems: () => headerItems });
216 return () => {233 return () => {
217 navigation.setOptions({ unstable_headerLeftItems: () => [] });234 navigation.setOptions({ unstable_headerLeftItems: () => [] });
218 };235 };
219 }236 }
220 navigation.setOptions({ unstable_headerRightItems: () => items });237 navigation.setOptions({ unstable_headerRightItems: () => headerItems });
221 return () => {238 return () => {
222 navigation.setOptions({ unstable_headerRightItems: () => [] });239 navigation.setOptions({ unstable_headerRightItems: () => [] });
223 };240 };
...@@ -228,6 +245,7 @@ function NativeHeaderToolbarRoot(props: {...@@ -228,6 +245,7 @@ function NativeHeaderToolbarRoot(props: {
228245
229function NativeHeaderToolbarButton(_props: {246function NativeHeaderToolbarButton(_props: {
230 readonly accessibilityLabel?: string;247 readonly accessibilityLabel?: string;
248 readonly axisBehavior?: NativeStackHeaderAxisBehavior;
231 readonly disabled?: boolean;249 readonly disabled?: boolean;
232 readonly icon?: string;250 readonly icon?: string;
233 readonly label?: string;251 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 @@...@@ -1,7 +1,29 @@
1import { requireOptionalNativeModule } from "expo";1import { requireOptionalNativeModule } from "expo";
2import { Platform } from "react-native";2import { Platform } from "react-native";
3const nativeControls = requireOptionalNativeModule<{ readonly supportsWorkspaceColumns?: boolean }>(3import { useNativeLayoutMetrics } from "./native-layout-metrics";
4 "T3NativeControls",4
5);5const nativeControls = requireOptionalNativeModule<{
6 readonly supportsWorkspaceColumns?: boolean;
7 readonly ViewPrototypes?: { readonly T3NativeControls_LayoutMetrics?: unknown };
8}>("T3NativeControls");
6export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED =9export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED =
7 Platform.OS === "ios" && Platform.isPad && nativeControls?.supportsWorkspaceColumns === true;10 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 @@...@@ -1,3 +1,11 @@
1import { Platform } from "react-native";1import { Platform } from "react-native";
2// Android retains its existing navigator. The iOS file checks native availability.2// Android retains its existing navigator. The iOS file checks native availability.
3export const NATIVE_WORKSPACE_COLUMNS_SUPPORTED = Platform.OS === "ios" && Platform.isPad;3export 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<...@@ -19,6 +19,8 @@ export type AppNativeStackNavigationOptions = Omit<
19 NativeStackNavigationOptions,19 NativeStackNavigationOptions,
20 "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems"20 "headerTintColor" | "unstable_headerLeftItems" | "unstable_headerRightItems"
21> & {21> & {
22 /** Nested navigator leaves apply their own horizontal safe area. */
23 readonly nativeContentInsetHorizontally?: boolean;
22 readonly headerTintColor?: string | ColorValue;24 readonly headerTintColor?: string | ColorValue;
23 readonly unstable_headerCenterItems?: unknown;25 readonly unstable_headerCenterItems?: unknown;
24 readonly unstable_headerLeftItems?: unknown;26 readonly unstable_headerLeftItems?: unknown;
apps/mobile/src/native/V5StackHeader.ios.tsx+23-1
...@@ -6,6 +6,7 @@ import type {...@@ -6,6 +6,7 @@ import type {
6import { isValidElement, useRef } from "react";6import { isValidElement, useRef } from "react";
7import { Platform, StyleSheet } from "react-native";7import { Platform, StyleSheet } from "react-native";
8import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme";8import { useMobileNavigationTheme } from "../lib/useMobileNavigationTheme";
9import { useNativeLayoutMetrics } from "../native/native-layout-metrics";
9import {10import {
10 SearchBar,11 SearchBar,
11 Stack,12 Stack,
...@@ -110,6 +111,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte...@@ -110,6 +111,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte
110 id,111 id,
111 identifier: item.identifier,112 identifier: item.identifier,
112 title: item.label || item.accessibilityLabel || undefined,113 title: item.label || item.accessibilityLabel || undefined,
114 axisBehavior: item.axisBehavior,
113 icon: convertIcon(item.icon),115 icon: convertIcon(item.icon),
114 menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`),116 menu: item.disabled ? undefined : convertMenu(item.menu, `${id}:menu`),
115 hidesSharedBackground: item.hidesSharedBackground,117 hidesSharedBackground: item.hidesSharedBackground,
...@@ -120,6 +122,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte...@@ -120,6 +122,7 @@ function convertItems(items: NativeStackHeaderItem[], prefix: string): HeaderIte
120 id,122 id,
121 identifier: item.identifier,123 identifier: item.identifier,
122 title: item.label || item.accessibilityLabel || undefined,124 title: item.label || item.accessibilityLabel || undefined,
125 axisBehavior: item.axisBehavior,
123 icon: convertIcon(item.icon),126 icon: convertIcon(item.icon),
124 onPress: item.disabled ? undefined : item.onPress,127 onPress: item.disabled ? undefined : item.onPress,
125 hidesSharedBackground: item.hidesSharedBackground,128 hidesSharedBackground: item.hidesSharedBackground,
...@@ -136,6 +139,7 @@ export function V5StackHeader(props: {...@@ -136,6 +139,7 @@ export function V5StackHeader(props: {
136}) {139}) {
137 const { options } = props;140 const { options } = props;
138 const theme = useMobileNavigationTheme();141 const theme = useMobileNavigationTheme();
142 const nativeMetrics = useNativeLayoutMetrics();
139 const searchRef = useRef<SearchBarCommands>(null);143 const searchRef = useRef<SearchBarCommands>(null);
140 const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack };144 const itemProps = { tintColor: options.headerTintColor, canGoBack: props.canGoBack };
141 const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading");145 const leading = convertItems(options.unstable_headerLeftItems?.(itemProps) ?? [], "leading");
...@@ -162,6 +166,7 @@ export function V5StackHeader(props: {...@@ -162,6 +166,7 @@ export function V5StackHeader(props: {
162 bottom.push({166 bottom.push({
163 type: "item",167 type: "item",
164 id: mailSearch.filterButtonId ?? "filter",168 id: mailSearch.filterButtonId ?? "filter",
169 axisBehavior: bottomSearch ? "horizontalOnly" : undefined,
165 icon: {170 icon: {
166 type: "sfSymbol",171 type: "sfSymbol",
167 name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease",172 name: mailSearch.filterSystemImageName ?? "line.3.horizontal.decrease",
...@@ -171,7 +176,12 @@ export function V5StackHeader(props: {...@@ -171,7 +176,12 @@ export function V5StackHeader(props: {
171 if (bottomSearch) {176 if (bottomSearch) {
172 if (bottom.length)177 if (bottom.length)
173 bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 });178 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 });
175 if (mailSearch.onComposePress)185 if (mailSearch.onComposePress)
176 bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 });186 bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 });
177 }187 }
...@@ -179,10 +189,22 @@ export function V5StackHeader(props: {...@@ -179,10 +189,22 @@ export function V5StackHeader(props: {
179 bottom.push({189 bottom.push({
180 type: "item",190 type: "item",
181 id: mailSearch.composeButtonId ?? "compose",191 id: mailSearch.composeButtonId ?? "compose",
192 axisBehavior: bottomSearch ? "horizontalOnly" : undefined,
182 icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" },193 icon: { type: "sfSymbol", name: mailSearch.composeSystemImageName ?? "square.and.pencil" },
183 onPress: mailSearch.onComposePress,194 onPress: mailSearch.onComposePress,
184 });195 });
185 }196 }
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 }
186 const left = options.headerLeft?.(itemProps);208 const left = options.headerLeft?.(itemProps);
187 const right = options.headerRight?.(itemProps);209 const right = options.headerRight?.(itemProps);
188 if (!options.unstable_headerLeftItems && isValidElement(left))210 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";...@@ -24,6 +24,7 @@ import { useCallback, useRef, useState, type ComponentProps } from "react";
24import { StyleSheet, View } from "react-native";24import { StyleSheet, View } from "react-native";
25import { FormSheet, Stack } from "react-native-screens";25import { FormSheet, Stack } from "react-native-screens";
26import { V5StackHeader } from "./V5StackHeader.ios";26import { V5StackHeader } from "./V5StackHeader.ios";
27import type { AppNativeStackNavigationOptions } from "./StackHeader";
27import { NativeColumnContent } from "./NativeColumnContent.ios";28import { NativeColumnContent } from "./NativeColumnContent.ios";
28import {29import {
29 nativeStackPopAction,30 nativeStackPopAction,
...@@ -232,7 +233,14 @@ export function V5CardStackView(props: V5StackViewProps) {...@@ -232,7 +233,14 @@ export function V5CardStackView(props: V5StackViewProps) {
232 : index > 0233 : index > 0
233 }234 }
234 />235 />
235 <NativeColumnContent>{descriptor.render()}</NativeColumnContent>236 <NativeColumnContent
237 insetHorizontally={
238 (descriptor.options as AppNativeStackNavigationOptions)
239 .nativeContentInsetHorizontally
240 }
241 >
242 {descriptor.render()}
243 </NativeColumnContent>
236 </NavigationRouteContext>244 </NavigationRouteContext>
237 </NavigationContext>245 </NavigationContext>
238 </Stack.Screen>246 </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 @@...@@ -1,7 +1,7 @@
1# Building the mobile Screens dependency1# Building the mobile Screens dependency
22
3The mobile v5 stack uses a committed tarball from the3The 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).
5The fork owns the integration changes; the app does not patch this package.5The fork owns the integration changes; the app does not patch this package.
6Each archive includes compiled JavaScript, declarations, native source and6Each archive includes compiled JavaScript, declarations, native source and
7`t3-fork.json` recording its version, source commit and upstream base.7`t3-fork.json` recording its version, source commit and upstream base.
...@@ -9,7 +9,7 @@ Each archive includes compiled JavaScript, declarations, native source and...@@ -9,7 +9,7 @@ Each archive includes compiled JavaScript, declarations, native source and
9To reproduce the current archive with Node 24 and the repository's pinned Yarn:9To reproduce the current archive with Node 24 and the repository's pinned Yarn:
1010
11```sh11```sh
12git clone --branch t3-v5.0.0-t3.7 https://github.com/juliusmarminge/react-native-screens.git /tmp/t3-screens-fork12git clone --branch t3-v5.0.0-t3.8 https://github.com/juliusmarminge/react-native-screens.git /tmp/t3-screens-fork
13yarn --cwd /tmp/t3-screens-fork pack:t3 /absolute/path/to/t3code/apps/mobile/deps13yarn --cwd /tmp/t3-screens-fork pack:t3 /absolute/path/to/t3code/apps/mobile/deps
14```14```
1515
...@@ -20,3 +20,8 @@ Run `pack:t3` from that clean checkout and tag the source as `t3-v<version>`....@@ -20,3 +20,8 @@ Run `pack:t3` from that clean checkout and tag the source as `t3-v<version>`.
20Replace the old archive, update `apps/mobile/package.json` and run `vp i` to20Replace the old archive, update `apps/mobile/package.json` and run `vp i` to
21regenerate the lockfile. Verify mobile types and affected navigation tests.21regenerate the lockfile. Verify mobile types and affected navigation tests.
22Changes to native source also require rebuilding and testing the native client.22Changes 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...@@ -67,7 +67,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d
67 }67 }
68 return item;68 return item;
69 }69 }
70+ if (item.type === 'mailSearchToolbar') {70+ if (item.type === 'mailSearchToolbar' || item.type === 'searchBarPlacement') {
71+ return {71+ return {
72+ ...item,72+ ...item,
73+ index73+ index
...@@ -81,7 +81,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d...@@ -81,7 +81,7 @@ index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d
81 return processedItem;81 return processedItem;
82 }82 }
83- throw new Error(`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom' and 'spacing'.`);83- 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'.`);
85 }).filter(item => item != null);85 }).filter(item => item != null);
86 };86 };
87 const transformIcon = icon => {87 const transformIcon = icon => {
...@@ -190,6 +190,10 @@ index 2f1351a89f0f2e67e854dc81e392198326b71834..ccf8556911fa1fa17588f06b56edb215...@@ -190,6 +190,10 @@ index 2f1351a89f0f2e67e854dc81e392198326b71834..ccf8556911fa1fa17588f06b56edb215
190 /**190 /**
191 * A React Element to display as the item.191 * A React Element to display as the item.
192 */192 */
193@@ -864,1 +864,3 @@
194 sharesBackground?: boolean;
195+ /** Native bar axis preference on iOS 27.1 and later. */
196+ axisBehavior?: 'automatic' | 'horizontalOnly' | 'verticalPreferred';
193diff --git a/src/types.tsx b/src/types.tsx197diff --git a/src/types.tsx b/src/types.tsx
194index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9eb16f1a80 100644198index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9eb16f1a80 100644
195--- a/src/types.tsx199--- a/src/types.tsx
...@@ -208,6 +212,10 @@ index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9e...@@ -208,6 +212,10 @@ index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9e
208 /**212 /**
209 * A React Element to display as the item.213 * A React Element to display as the item.
210 */214 */
215@@ -920,1 +920,3 @@
216 sharesBackground?: boolean;
217+ /** Native bar axis preference on iOS 27.1 and later. */
218+ axisBehavior?: 'automatic' | 'horizontalOnly' | 'verticalPreferred';
211diff --git a/src/views/useHeaderConfigProps.tsx b/src/views/useHeaderConfigProps.tsx219diff --git a/src/views/useHeaderConfigProps.tsx b/src/views/useHeaderConfigProps.tsx
212index 3f3fff405048b3c2250afbe99dd7f35cecb9a6d5..bb062b898cfa77f8c02f951dc9d81b51672d7d0e 100644220index 3f3fff405048b3c2250afbe99dd7f35cecb9a6d5..bb062b898cfa77f8c02f951dc9d81b51672d7d0e 100644
213--- a/src/views/useHeaderConfigProps.tsx221--- a/src/views/useHeaderConfigProps.tsx
pnpm-lock.yaml+10-10
...@@ -154,7 +154,7 @@ patchedDependencies:...@@ -154,7 +154,7 @@ patchedDependencies:
154 '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0154 '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0
155 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d155 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
156 '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba156 '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba
157 '@react-navigation/native-stack@7.17.6': 3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87157 '@react-navigation/native-stack@7.17.6': d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5
158 alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd158 alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd
159 dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4159 dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4
160 effect@4.0.2: ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688160 effect@4.0.2: ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688
...@@ -346,7 +346,7 @@ importers:...@@ -346,7 +346,7 @@ importers:
346 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)346 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)
347 '@react-navigation/native-stack':347 '@react-navigation/native-stack':
348 specifier: 7.17.6348 specifier: 7.17.6
349 version: 7.17.6(patch_hash=3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87)(141a742cd91f55378cde709a48a3af06)349 version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(cc0272b81e52c9a28f5defc327cee1b5)
350 '@shikijs/core':350 '@shikijs/core':
351 specifier: 4.2.0351 specifier: 4.2.0
352 version: 4.2.0352 version: 4.2.0
...@@ -549,8 +549,8 @@ importers:...@@ -549,8 +549,8 @@ importers:
549 specifier: ~5.9.1549 specifier: ~5.9.1
550 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)550 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)
551 react-native-screens:551 react-native-screens:
552 specifier: file:deps/react-native-screens-5.0.0-t3.7.tgz552 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.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)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)
554 react-native-shiki-engine:554 react-native-shiki-engine:
555 specifier: ^0.3.12555 specifier: ^0.3.12
556 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)556 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:...@@ -10798,9 +10798,9 @@ packages:
10798 react: '*'10798 react: '*'
10799 react-native: '*'10799 react-native: '*'
1080010800
10801 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.tgz:10801 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.8.tgz:
10802 resolution: {integrity: sha512-Di1+YTCuhWpk9helKBxkvXAVAQTCSRo24tpFBGXdbTAT4vPm2vGPbtPe+lnIXcePjNHbTEaI1ha5n9PqQKl5lg==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.7.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.710803 version: 5.0.0-t3.8
10804 peerDependencies:10804 peerDependencies:
10805 react: '*'10805 react: '*'
10806 react-native: '*'10806 react-native: '*'
...@@ -15971,7 +15971,7 @@ snapshots:...@@ -15971,7 +15971,7 @@ snapshots:
15971 optionalDependencies:15971 optionalDependencies:
15972 '@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)15972 '@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)':
15975 dependencies:15975 dependencies:
15976 '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be)15976 '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be)
15977 '@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)15977 '@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:...@@ -15979,7 +15979,7 @@ snapshots:
15979 react: 19.3.015979 react: 19.3.0
15980 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)15980 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)
15981 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)15981 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)
15983 sf-symbols-typescript: 2.2.015983 sf-symbols-typescript: 2.2.0
15984 warn-once: 0.1.115984 warn-once: 0.1.1
15985 transitivePeerDependencies:15985 transitivePeerDependencies:
...@@ -21967,7 +21967,7 @@ snapshots:...@@ -21967,7 +21967,7 @@ snapshots:
21967 react: 19.3.021967 react: 19.3.0
21968 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)21968 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):
21971 dependencies:21971 dependencies:
21972 react: 19.3.021972 react: 19.3.0
21973 react-freeze: 1.0.4(react@19.3.0)21973 react-freeze: 1.0.4(react@19.3.0)