# Files changed · PR \#23150 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23150/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(mobile\): the iPhone Duo's search field and composer line up with its tab bar](https://git.cafe/pingdotgg/t3code/pull/23150.md)

Version: 12

Head commit: 5877d50a540f0e8d4cc0e6b8d51252a431443901

Base commit: 2b3f7fbf39e2eb82d540e41553db84c46c8760f7

Comparison base: 2b3f7fbf39e2eb82d540e41553db84c46c8760f7

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/modules/t3-native-controls/ios/T3LayoutMetricsView.swift

Status: modified; +5; −1

```diff
@@ -69,6 +69,7 @@
     }
     var verticalBarEdge = "none"
     var regions: [[String: Any]] = []
+    var bottomBarInset: CGFloat?
     #if canImport(UIKit, _version: 9127.0.85)
     if #available(iOS 27.1, *) {
       let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft
@@ -77,6 +78,8 @@
       case .trailing: verticalBarEdge = rtl ? "left" : "right"
       default: break
       }
+      // Where UIKit floats a bar as tall as the app's bottom controls, level with its tab bar.
+      bottomBarInset = edgeInsets(for: .bar(onEdge: UIRectEdge.bottom, extent: 48)).bottom
       for kind in [UIView.ReservedRegion.Kind.division, .occlusion] {
         for region in reservedRegions(kind: kind) where region.isActive {
           let frame = region.frame.intersection(bounds)
@@ -90,7 +93,7 @@
       }
     }
     #endif
-    let metrics: [String: Any] = [
+    var metrics: [String: Any] = [
       "width": bounds.width, "height": bounds.height,
       "hasHinge": hasHinge,
       "horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact",
@@ -101,6 +104,7 @@
       ],
       "reservedRegions": regions,
     ]
+    metrics["bottomBarInset"] = bottomBarInset
     let next = metrics as NSDictionary
     guard lastMetrics?.isEqual(next) != true else { return }
     lastMetrics = next
```

## apps/mobile/src/features/threads/ThreadComposer.tsx

Status: modified; +4; −1

```diff
@@ -349,6 +349,9 @@
   );
 }
 
+/** Space below the composer pill, inside the bottom inset it is given. */
+export const THREAD_COMPOSER_BOTTOM_GUTTER = 6;
+
 export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps) {
   const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId));
   const { themeVariables: materialTheme } = useAppearancePreferences();
@@ -804,7 +807,7 @@
       className="px-[12px]"
       style={{
         paddingTop: usesCard ? 8 : 6,
-        paddingBottom: (props.bottomInset ?? 0) + (usesCard ? 8 : 6),
+        paddingBottom: (props.bottomInset ?? 0) + (usesCard ? 8 : THREAD_COMPOSER_BOTTOM_GUTTER),
         backgroundColor:
           Platform.OS === "android" ? themeColorWithAlpha(composerPanel, 1) : undefined,
       }}
```

## apps/mobile/src/features/threads/ThreadDetailScreen.tsx

Status: modified; +9; −2

```diff
@@ -102,7 +102,10 @@
 import { useSafeAreaInsets } from "react-native-safe-area-context";
 import { useWorkspaceContentWidth } from "../layout/workspace-content-width";
 import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
+import {
+  useDuoBottomBarInset,
-import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
+  useNativeColumnLayoutMetrics,
+} from "../../native/native-layout-metrics";
 import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
 import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
 import type { ComposerEditorHandle } from "../../components/ComposerEditor";
@@ -159,6 +162,7 @@
   COMPOSER_EXPANDED_CHROME,
   COMPOSER_LAYOUT_TRANSITION,
   COMPOSER_TRANSITION_DURATION_MS,
+  THREAD_COMPOSER_BOTTOM_GUTTER,
   ThreadComposer,
 } from "./ThreadComposer";
 import { ComposerPopoverHost } from "./ComposerPopoverHost";
@@ -536,10 +540,13 @@
   const [endFollowEnabled, setEndFollowEnabled] = useState(true);
   // Android Back hides the keyboard without blurring. Keep its safe-area padding
   // fixed through the animation and cancel it with matching composer/list offsets.
+  const bottomBarInset = useDuoBottomBarInset();
   const composerBottomInset =
     Platform.OS === "ios" && (composerExpanded || composerFocused)
       ? 0
+      : bottomBarInset !== undefined
+        ? bottomBarInset - THREAD_COMPOSER_BOTTOM_GUTTER
-      : Math.max(insets.bottom, 12);
+        : Math.max(insets.bottom, 12);
   const composerKeyboardOffset = Platform.OS === "android" ? composerBottomInset : 0;
   const contentPresentationKind = props.contentPresentation.kind;
   const realThreadSyncLabel = (() => {
```

## apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx

Status: modified; +5; −1

```diff
@@ -11,6 +11,7 @@
 import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
 import {
   useDeviceType,
+  useDuoBottomBarInset,
   useNativeColumnLayoutMetrics,
   useNativeLayoutMetrics,
 } from "../../native/native-layout-metrics";
@@ -70,6 +71,7 @@
       (nativeMetrics !== null && nativeMetrics.verticalBarEdge !== "none"));
   const width = columnMetrics?.width ?? windowWidth;
   const insets = useSafeAreaInsets();
+  const bottomBarInset = useDuoBottomBarInset();
   const keyboardHeight = useKeyboardState((keyboard) => keyboard.height);
   const workspace = useWorkspaceTabs()!;
   const { state } = workspace;
@@ -90,7 +92,9 @@
   const tab = state.tabs[state.selectedTab];
   const bottomClearance =
     Platform.OS === "ios" && isDuoDevice(deviceType)
-      ? Math.max(insets.bottom, 12, keyboardHeight + 8) + WORKSPACE_TOOLBAR.height + 16
+      ? Math.max(bottomBarInset ?? Math.max(insets.bottom, 12), keyboardHeight + 8) +
+        WORKSPACE_TOOLBAR.height +
+        16
       : Platform.OS === "ios"
         ? Math.max(insets.bottom, keyboardHeight) +
           (systemTabBar || sidebarTabs || tab.searchMode !== "resting"
```

## apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx

Status: modified; +3; −2

```diff
@@ -6,7 +6,7 @@
 import Animated, { useAnimatedStyle } from "react-native-reanimated";
 import { useSafeAreaInsets } from "react-native-safe-area-context";
 import { isDuoDevice } from "../../lib/device-type";
-import { useDeviceType } from "../../native/native-layout-metrics";
+import { useDeviceType, useDuoBottomBarInset } from "../../native/native-layout-metrics";
 import type { SearchBarCommands } from "react-native-screens";
 
 import { SymbolView } from "../../components/ui/AppSymbol";
@@ -75,6 +75,7 @@
   // each fold replaces the search field.
   const listSearch = isDuoDevice(deviceType);
   const insets = useSafeAreaInsets();
+  const bottomBarInset = useDuoBottomBarInset();
   const { isTabActive } = workspace;
   const tabActive = use(NativeWorkspaceTabActivityContext)?.active ?? true;
   const { panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
@@ -216,7 +217,7 @@
         ) : null}
       </Toolbar>
       {listSearch ? (
-        <AboveKeyboard bottom={Math.max(insets.bottom, 12)}>
+        <AboveKeyboard bottom={bottomBarInset ?? Math.max(insets.bottom, 12)}>
           <WorkspaceSearchField
             ref={fieldRef}
             tab={selectedTab}
```

## apps/mobile/src/lib/reserved-regions.ts

Status: modified; +2; −0

```diff
@@ -19,6 +19,8 @@
     readonly right: number;
   };
   readonly reservedRegions: ReadonlyArray<ReservedRegion>;
+  /** How far above the bottom edge UIKit floats a 48pt bottom bar, level with its tab bar. iOS 27.1+. */
+  readonly bottomBarInset?: number;
 }
 
 export function deriveBottomControlInsets(metrics: NativeLayoutMetrics | null) {
```

## apps/mobile/src/native/native-layout-metrics.tsx

Status: modified; +11; −1

```diff
@@ -1,6 +1,6 @@
 import { createContext, use, useState, type ReactNode } from "react";
 import { Platform } from "react-native";
-import { deriveDeviceType, type DeviceType } from "../lib/device-type";
+import { deriveDeviceType, isDuoDevice, type DeviceType } from "../lib/device-type";
 import type { NativeLayoutMetrics } from "../lib/reserved-regions";
 import { NativeLayoutObserver } from "./NativeLayoutObserver";
 
@@ -38,3 +38,13 @@
 export function useDeviceType() {
   return use(DeviceTypeContext);
 }
+
+/**
+ * Where the Duo's floating bottom controls end in this column. Its tab bar floats below the safe
+ * area, and the controls beside it sit on the same line.
+ */
+export function useDuoBottomBarInset() {
+  const deviceType = useDeviceType();
+  const inset = use(NativeColumnLayoutMetricsContext)?.bottomBarInset;
+  return isDuoDevice(deviceType) ? inset : undefined;
+}
```
