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 }); }