diff --git a/apps/mobile/src/features/layout/inspector-host.tsx b/apps/mobile/src/features/layout/inspector-host.tsx
index 4a24eafb9b6da..368b44f64988b 100644
--- a/apps/mobile/src/features/layout/inspector-host.tsx
+++ b/apps/mobile/src/features/layout/inspector-host.tsx
@@ -23,7 +23,14 @@ const PANE_HEADER_OPTIONS = { ...GLASS_HEADER_OPTIONS, headerShown: false };
function PaneHeader(props: { readonly store: InspectorBarStore }) {
const options = useSyncExternalStore(props.store.subscribe, props.store.getSnapshot);
- return ;
+ // Its own search identity, so opening the inspector doesn't morph the sidebar's search into it.
+ return (
+
+ );
}
function PaneScreenContent(props: {
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index 7cbc2a4091bd7..692518729f449 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -114,7 +114,10 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useBarOverlap } from "../../components/ui/useBarInset";
-import { useWorkspaceContentWidth } from "../layout/workspace-content-width";
+import {
+ useWorkspaceContentWidth,
+ WorkspaceSettledContentWidthContext,
+} from "../layout/workspace-content-width";
import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context";
import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
import {
@@ -317,6 +320,17 @@ export interface ThreadDetailScreenProps {
// iOS questions overlay the controls; Android questions stack with child approvals.
const ComposerPanels = Platform.OS === "android" ? Animated.ScrollView : Animated.View;
+
+/** Hands the feed the screen width measured for a native column; elsewhere keeps the inherited one. */
+function ColumnWidth(props: { readonly width: number | null; readonly children: React.ReactNode }) {
+ return props.width === null ? (
+ props.children
+ ) : (
+
+ {props.children}
+
+ );
+}
const USAGE_LIMITS_PANEL_GAP = 12;
const USAGE_LIMITS_PANEL_MIN_HEIGHT = 44;
@@ -1022,8 +1036,13 @@ function ThreadDetailBody(
// Android lays the screen out below its opaque header and reports a zero
// header height, so the window height overstates the room by the header.
const [screenHeight, setScreenHeight] = useState(null);
+ const [screenWidth, setScreenWidth] = useState(null);
const handleScreenLayout = (event: LayoutChangeEvent) => {
setScreenHeight(event.nativeEvent.layout.height);
+ const width = Math.round(event.nativeEvent.layout.width);
+ setScreenWidth((current) =>
+ current !== null && Math.abs(current - width) <= 1 ? current : width,
+ );
};
const screenAreaHeight = screenHeight ?? windowHeight;
const draftViewportHeight = screenAreaHeight - navigationHeaderHeight - liveKeyboardHeight;
@@ -1211,6 +1230,13 @@ function ThreadDetailBody(
}
: { width: "auto", right: controlInsets.right },
);
+ // A native column resizes the screen before JS hears of it. The feed and the composer take the
+ // measured width in one render, so they change size together rather than the composer first.
+ const columnWidth = usesNativeWorkspaceColumns && isSplitLayout ? screenWidth : null;
+ const columnComposerWidthStyle =
+ columnWidth === null
+ ? null
+ : { width: Math.max(0, columnWidth - controlInsets.left - controlInsets.right) };
const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
useAtomMount(threadStreamHaptics(props.selectedThreadDetailRef));
const selectedProviderSkills = (() => {
@@ -1436,54 +1462,58 @@ function ThreadDetailBody(
)}
>
- finishThreadMessageJump(selectedThreadKey)}
- contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
- keyboardOffset={composerKeyboardOffset}
- sendingWithKeyboard={sendingWithKeyboard}
- fitShortFeedToViewport={questionInFlow && !isKeyboardVisible}
- contentTopInset={0}
- contentBottomInset={
- questionInFlow
- ? 0
- : estimatedOverlayHeight +
- (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
- }
- contentMaxWidth={contentMaxWidth}
- historyControls={props.historyControls}
- contentSideInsets={nativeMetrics?.safeArea}
- layoutVariant={layoutVariant}
- usesAutomaticContentInsets={props.usesAutomaticContentInsets}
- onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
- onIsAtEndChange={setIsAtEnd}
- skills={selectedProviderSkills}
- onUseArtifactTemplate={handleUseArtifactTemplate}
- />
+
+ finishThreadMessageJump(selectedThreadKey)}
+ contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
+ keyboardOffset={composerKeyboardOffset}
+ sendingWithKeyboard={sendingWithKeyboard}
+ fitShortFeedToViewport={questionInFlow && !isKeyboardVisible}
+ contentTopInset={0}
+ contentBottomInset={
+ questionInFlow
+ ? 0
+ : estimatedOverlayHeight +
+ (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
+ }
+ contentMaxWidth={contentMaxWidth}
+ historyControls={props.historyControls}
+ contentSideInsets={nativeMetrics?.safeArea}
+ layoutVariant={layoutVariant}
+ usesAutomaticContentInsets={props.usesAutomaticContentInsets}
+ onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
+ onIsAtEndChange={setIsAtEnd}
+ skills={selectedProviderSkills}
+ onUseArtifactTemplate={handleUseArtifactTemplate}
+ />
+
@@ -1533,18 +1563,25 @@ function ThreadDetailBody(
pointerEvents="box-none"
style={
questionInFlow
- ? isSplitLayout
- ? composerWidthStyle
- : undefined
- : [
- {
- position: "absolute",
- bottom: controlInsets.bottom,
- left: controlInsets.left,
- right: controlInsets.right,
- },
- composerWidthStyle,
- ]
+ ? (columnComposerWidthStyle ?? (isSplitLayout ? composerWidthStyle : undefined))
+ : columnComposerWidthStyle
+ ? [
+ {
+ position: "absolute",
+ bottom: controlInsets.bottom,
+ left: controlInsets.left,
+ },
+ columnComposerWidthStyle,
+ ]
+ : [
+ {
+ position: "absolute",
+ bottom: controlInsets.bottom,
+ left: controlInsets.left,
+ right: controlInsets.right,
+ },
+ composerWidthStyle,
+ ]
}
>
{/* No paddingTop here: the overlay's measured height becomes the
diff --git a/apps/mobile/src/native/V5StackHeader.ios.tsx b/apps/mobile/src/native/V5StackHeader.ios.tsx
index f53e71ee23f68..c0df2b25618a9 100644
--- a/apps/mobile/src/native/V5StackHeader.ios.tsx
+++ b/apps/mobile/src/native/V5StackHeader.ios.tsx
@@ -156,6 +156,8 @@ export function V5StackHeader(props: {
readonly column?: "primary" | "detail";
/** The workspace's compose action, shown as its own group in this column's bar. */
readonly onCompose?: () => void;
+ /** UIKit morphs search items that share an identifier between bars as columns change. */
+ readonly searchIdentifier?: string;
}) {
const { options } = props;
const theme = useMobileNavigationTheme();
@@ -182,7 +184,14 @@ export function V5StackHeader(props: {
const bottom = toolbar.flatMap((item, index) => {
if (item.type === "mailSearchToolbar") return [];
if (item.type === "searchBarPlacement")
- return [{ type: "item", id: `toolbar-search:${index}`, searchBarPlacement: true }];
+ return [
+ {
+ type: "item",
+ id: `toolbar-search:${index}`,
+ searchBarPlacement: true,
+ ...(props.searchIdentifier ? { identifier: props.searchIdentifier } : {}),
+ },
+ ];
return convertItems([item], `toolbar:${index}`);
});
if (mailSearch) {
@@ -199,7 +208,12 @@ export function V5StackHeader(props: {
if (bottomSearch) {
if (spacesSearch && bottom.length)
bottom.push({ type: "spacer", id: "before-search", sizing: "fixed", width: 8 });
- bottom.push({ type: "item", id: "home-search", searchBarPlacement: true });
+ bottom.push({
+ type: "item",
+ id: "home-search",
+ searchBarPlacement: true,
+ ...(props.searchIdentifier ? { identifier: props.searchIdentifier } : {}),
+ });
if (spacesSearch && mailSearch.onComposePress)
bottom.push({ type: "spacer", id: "after-search", sizing: "fixed", width: 8 });
}