From 7798f459dafa5ae2749357c946f52f9f74e5b2f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:31:37 -0800 Subject: [PATCH 1/2] fix(mobile): the composer resizes with the thread feed when an iOS column changes The composer followed the native column directly while the feed waits for the render that hands it its new width, so the two changed size frames apart and the composer appeared to shift on its own. On the native columns the thread screen now measures its width and gives the feed and the composer that width in one render. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadDetailScreen.tsx | 159 +++++++++++------- 1 file changed, 98 insertions(+), 61 deletions(-) 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 From c7b56db893d907c067ec14b253c7fc68f1211965 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:49:37 -0800 Subject: [PATCH 2/2] fix(mobile): opening Files no longer flies the sidebar's search across the thread on iOS The sidebar's and the inspector's search toolbar items had no identifier, so UIKit treated them as one item and morphed the search field across the composer whenever the inspector replaced the sidebar. Give the inspector's search its own identifier; each column's search now leaves and arrives with its column. Co-Authored-By: Claude Opus 5.5 --- .../src/features/layout/inspector-host.tsx | 9 ++++++++- apps/mobile/src/native/V5StackHeader.ios.tsx | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) 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/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 }); }