From 9826562135d5e32b038365146e3062ccc9548c0d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 23:41:28 -0700 Subject: [PATCH 1/4] fix(mobile): the Android split slides as one rigid strip with the keyboard The sidebar and the detail column keep the widths React laid them out at and only translate, driven by the keyboard's progress on the UI thread, so a keyboard transition relays nothing out per frame. Co-Authored-By: Claude Opus 5.5 --- .../layout/AdaptiveWorkspaceLayout.tsx | 62 ++++++++++++++----- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 9038cfb94c03c..1731aec8c3c6b 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -806,6 +806,9 @@ function AdaptiveWorkspaceLayoutContent( // Showing or hiding the sidebar clips a fixed-width list. A hinge resize instead tracks the // animated edge, so the header actions slide with it rather than jumping or being covered. const listPaneGap = layout.listPaneGap ?? 0; + const keyboardSidebarRest = sidebarRestsOpen + ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) + : 0; const drawnSidebarWidth = useDerivedValue( () => renderedSidebarWidth.value * (keyboardHold.value ? 1 - keyboardProgress.value : 1), ); @@ -814,15 +817,29 @@ function AdaptiveWorkspaceLayoutContent( listPaneCeiling, Math.max(listPaneFloor, drawnSidebarWidth.value - listPaneGap), ), - // Following the keyboard, the list rides the sidebar's edge like the content beside it, - // instead of being uncovered in place. - transform: [{ translateX: drawnSidebarWidth.value - renderedSidebarWidth.value }], })); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ // Fading a UIKit glass ancestor can leave remounted header effects missing. opacity: - Platform.OS === "ios" || floatingTabsEnabled ? 1 : Math.min(1, drawnSidebarWidth.value / 80), - width: drawnSidebarWidth.value, + keyboardHold.value || Platform.OS === "ios" || floatingTabsEnabled + ? 1 + : Math.min(1, drawnSidebarWidth.value / 80), + // Held by the keyboard, it keeps its resting width and slides, so nothing relays out. + width: keyboardHold.value ? renderedSidebarWidth.value : drawnSidebarWidth.value, + transform: [ + { + translateX: keyboardHold.value ? drawnSidebarWidth.value - renderedSidebarWidth.value : 0, + }, + ], + })); + // The content keeps the width React laid it out at and rides the sidebar's edge, so a keyboard + // transition relays nothing out per frame, the way UIKit animates the iOS split view. + const contentKeyboardStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateX: keyboardHold.value ? drawnSidebarWidth.value - renderedSidebarWidth.value : 0, + }, + ], })); // Clip the navigator at its settled width so native headers and markdown reflow only once per pane toggle. @@ -922,6 +939,10 @@ function AdaptiveWorkspaceLayoutContent( // content pane, laid out after it, covers it, in the same mount as the page. style={[ workspacePage ? { position: "absolute", top: 0, bottom: 0, left: 0 } : null, + // Out of the row while the keyboard slides it. + keyboardHoldsSidebar + ? { position: "absolute", top: 0, bottom: 0, left: navigationRailWidth } + : null, sidebarAnimatedStyle, ]} > @@ -975,20 +996,30 @@ function AdaptiveWorkspaceLayoutContent( ) : null} - {/* Android holds the content still, so its floating controls keep its width. */} - + Date: Tue, 27 Oct 2026 23:54:28 -0700 Subject: [PATCH 2/4] fix(mobile): the Android navigation rail slides with the keyboard strip The rail ran its own timer, so the detail column landed a rail-width short and covered the sidebar's corner until it caught up. Co-Authored-By: Claude Opus 5.5 --- .../layout/AdaptiveWorkspaceLayout.tsx | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 1731aec8c3c6b..313cb9fe93004 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -381,6 +381,10 @@ function AdaptiveWorkspaceLayoutContent( width, ], ); + const keyboardRailRest = + keyboardHoldsSidebar && shouldRenderNavigationRail && sidebarRestsOpen + ? ANDROID_WORKSPACE_RAIL_WIDTH + : 0; const activeThread = parseActiveThreadPath(pathname); const environmentId = activeThread?.environmentId ?? null; const threadId = activeThread?.threadId ?? null; @@ -768,7 +772,12 @@ function AdaptiveWorkspaceLayoutContent( renderedSidebarWidth.set(renderedSidebarWidth.get() * (1 - keyboardProgress.get())); } keyboardHold.set(keyboardHoldsSidebar); - renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING)); + // Held by the keyboard, the rail keeps its resting width and rides the strip below. + renderedRailWidth.set( + keyboardHoldsSidebar + ? keyboardRailRest + : withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING), + ); // A workspace page hides the sidebar through React instead, in the same commit that mounts // the page. Animating it here would hide it a few frames before the page appears. if (workspacePage) { @@ -793,6 +802,7 @@ function AdaptiveWorkspaceLayoutContent( layout.listPaneGap, panes.primarySidebarVisible, keyboardHoldsSidebar, + keyboardRailRest, keyboardHold, keyboardProgress, sidebarRestsOpen, @@ -812,6 +822,15 @@ function AdaptiveWorkspaceLayoutContent( const drawnSidebarWidth = useDerivedValue( () => renderedSidebarWidth.value * (keyboardHold.value ? 1 - keyboardProgress.value : 1), ); + // How far the keyboard has slid the rail, sidebar, and content strip to the left. + const keyboardShift = useDerivedValue(() => + keyboardHold.value + ? -(renderedRailWidth.value + renderedSidebarWidth.value) * keyboardProgress.value + : 0, + ); + const keyboardShiftStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: keyboardShift.value }], + })); const listPaneAnimatedStyle = useAnimatedStyle(() => ({ width: Math.min( listPaneCeiling, @@ -826,21 +845,11 @@ function AdaptiveWorkspaceLayoutContent( : Math.min(1, drawnSidebarWidth.value / 80), // Held by the keyboard, it keeps its resting width and slides, so nothing relays out. width: keyboardHold.value ? renderedSidebarWidth.value : drawnSidebarWidth.value, - transform: [ - { - translateX: keyboardHold.value ? drawnSidebarWidth.value - renderedSidebarWidth.value : 0, - }, - ], + transform: [{ translateX: keyboardShift.value }], })); - // The content keeps the width React laid it out at and rides the sidebar's edge, so a keyboard + // The content keeps the width React laid it out at and rides the same strip, so a keyboard // transition relays nothing out per frame, the way UIKit animates the iOS split view. - const contentKeyboardStyle = useAnimatedStyle(() => ({ - transform: [ - { - translateX: keyboardHold.value ? drawnSidebarWidth.value - renderedSidebarWidth.value : 0, - }, - ], - })); + const contentKeyboardStyle = keyboardShiftStyle; // Clip the navigator at its settled width so native headers and markdown reflow only once per pane toggle. const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); @@ -941,7 +950,7 @@ function AdaptiveWorkspaceLayoutContent( workspacePage ? { position: "absolute", top: 0, bottom: 0, left: 0 } : null, // Out of the row while the keyboard slides it. keyboardHoldsSidebar - ? { position: "absolute", top: 0, bottom: 0, left: navigationRailWidth } + ? { position: "absolute", top: 0, bottom: 0, left: keyboardRailRest } : null, sidebarAnimatedStyle, ]} @@ -1083,12 +1092,17 @@ function AdaptiveWorkspaceLayoutContent( setAuxiliaryPaneWidth={setAuxiliaryPaneWidth} onClosed={handleWorkspaceInspectorClosed} /> - {shouldRenderNavigationRail && navigationRailWidth > 0 ? ( + {shouldRenderNavigationRail && (navigationRailWidth > 0 || keyboardRailRest > 0) ? ( // The rail reserves its slot above and draws over the panes, so it can widen over // the list in place. It takes the whole workspace only while it is open. - - + ) : null} ); From ad7f1d63b1797102da1b9172a0c1697f9a9eec0f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 00:09:30 -0700 Subject: [PATCH 3/4] fix(mobile): the Android navigation rail stays while the keyboard collapses the sidebar Only the list pane collapses, as Material's rail does. Riding the strip, the rail pushed the detail column past its own column and clipped it. Co-Authored-By: Claude Opus 5.5 --- .../layout/AdaptiveWorkspaceLayout.tsx | 55 +++++++------------ 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 313cb9fe93004..832e0457041d7 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -272,8 +272,9 @@ function AdaptiveWorkspaceLayoutContent( const enabledTabs = useEnabledNavigationTabs(); const { scale, fabSize } = useAndroidControlSizing(); const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1, scale, fabSize); + // The rail stays while the keyboard sets the sidebar aside; only the list pane collapses. const navigationRailWidth = - shouldRenderNavigationRail && (showPrimarySidebar || workspacePage) + shouldRenderNavigationRail && (sidebarWithoutKeyboard || workspacePage) ? ANDROID_WORKSPACE_RAIL_WIDTH : 0; const [hinge, setHinge] = useState(null); @@ -381,10 +382,6 @@ function AdaptiveWorkspaceLayoutContent( width, ], ); - const keyboardRailRest = - keyboardHoldsSidebar && shouldRenderNavigationRail && sidebarRestsOpen - ? ANDROID_WORKSPACE_RAIL_WIDTH - : 0; const activeThread = parseActiveThreadPath(pathname); const environmentId = activeThread?.environmentId ?? null; const threadId = activeThread?.threadId ?? null; @@ -772,12 +769,7 @@ function AdaptiveWorkspaceLayoutContent( renderedSidebarWidth.set(renderedSidebarWidth.get() * (1 - keyboardProgress.get())); } keyboardHold.set(keyboardHoldsSidebar); - // Held by the keyboard, the rail keeps its resting width and rides the strip below. - renderedRailWidth.set( - keyboardHoldsSidebar - ? keyboardRailRest - : withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING), - ); + renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING)); // A workspace page hides the sidebar through React instead, in the same commit that mounts // the page. Animating it here would hide it a few frames before the page appears. if (workspacePage) { @@ -802,7 +794,6 @@ function AdaptiveWorkspaceLayoutContent( layout.listPaneGap, panes.primarySidebarVisible, keyboardHoldsSidebar, - keyboardRailRest, keyboardHold, keyboardProgress, sidebarRestsOpen, @@ -822,15 +813,6 @@ function AdaptiveWorkspaceLayoutContent( const drawnSidebarWidth = useDerivedValue( () => renderedSidebarWidth.value * (keyboardHold.value ? 1 - keyboardProgress.value : 1), ); - // How far the keyboard has slid the rail, sidebar, and content strip to the left. - const keyboardShift = useDerivedValue(() => - keyboardHold.value - ? -(renderedRailWidth.value + renderedSidebarWidth.value) * keyboardProgress.value - : 0, - ); - const keyboardShiftStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: keyboardShift.value }], - })); const listPaneAnimatedStyle = useAnimatedStyle(() => ({ width: Math.min( listPaneCeiling, @@ -845,11 +827,21 @@ function AdaptiveWorkspaceLayoutContent( : Math.min(1, drawnSidebarWidth.value / 80), // Held by the keyboard, it keeps its resting width and slides, so nothing relays out. width: keyboardHold.value ? renderedSidebarWidth.value : drawnSidebarWidth.value, - transform: [{ translateX: keyboardShift.value }], + transform: [ + { + translateX: keyboardHold.value ? drawnSidebarWidth.value - renderedSidebarWidth.value : 0, + }, + ], })); - // The content keeps the width React laid it out at and rides the same strip, so a keyboard + // The content keeps the width React laid it out at and rides the sidebar's edge, so a keyboard // transition relays nothing out per frame, the way UIKit animates the iOS split view. - const contentKeyboardStyle = keyboardShiftStyle; + const contentKeyboardStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateX: keyboardHold.value ? drawnSidebarWidth.value - renderedSidebarWidth.value : 0, + }, + ], + })); // Clip the navigator at its settled width so native headers and markdown reflow only once per pane toggle. const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); @@ -950,7 +942,7 @@ function AdaptiveWorkspaceLayoutContent( workspacePage ? { position: "absolute", top: 0, bottom: 0, left: 0 } : null, // Out of the row while the keyboard slides it. keyboardHoldsSidebar - ? { position: "absolute", top: 0, bottom: 0, left: keyboardRailRest } + ? { position: "absolute", top: 0, bottom: 0, left: navigationRailWidth } : null, sidebarAnimatedStyle, ]} @@ -1092,17 +1084,12 @@ function AdaptiveWorkspaceLayoutContent( setAuxiliaryPaneWidth={setAuxiliaryPaneWidth} onClosed={handleWorkspaceInspectorClosed} /> - {shouldRenderNavigationRail && (navigationRailWidth > 0 || keyboardRailRest > 0) ? ( + {shouldRenderNavigationRail && navigationRailWidth > 0 ? ( // The rail reserves its slot above and draws over the panes, so it can widen over // the list in place. It takes the whole workspace only while it is open. - - + ) : null} ); From 11d55f7fad60a5f9a90325eaf901806049314852 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 00:33:21 -0700 Subject: [PATCH 4/4] fix(mobile): the detail card fills the strip it leaves while the keyboard slides it back Co-Authored-By: Claude Opus 5.5 --- .../layout/AdaptiveWorkspaceLayout.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 832e0457041d7..f4f36f2d9c099 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -8,6 +8,7 @@ import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area import { AdaptiveWorkspaceContext, useAdaptiveWorkspaceLayout } from "./adaptive-workspace-context"; import { threadSelectionDeferral } from "./deferred-navigation"; import { uuidv4 } from "../../lib/uuid"; +import { useToolbarLayout } from "../../components/ui/useToolbarLayout"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -835,6 +836,19 @@ function AdaptiveWorkspaceLayoutContent( })); // The content keeps the width React laid it out at and rides the sidebar's edge, so a keyboard // transition relays nothing out per frame, the way UIKit animates the iOS split view. + // While the keyboard is moving, the detail card continues past its right edge, so the strip the + // narrower settled column leaves on the way back shows the card instead of the row behind it. + const contentOverhangStyle = useAnimatedStyle(() => ({ + opacity: keyboardHold.value && keyboardProgress.value > 0 && keyboardProgress.value < 1 ? 1 : 0, + transform: [ + { + translateX: keyboardHold.value ? drawnSidebarWidth.value - renderedSidebarWidth.value : 0, + }, + ], + })); + const threadHeader = useToolbarLayout(false, composerKeyboardActive); + const threadHeaderHeight = + threadHeader.paddingTop + threadHeader.height + threadHeader.paddingBottom; const contentKeyboardStyle = useAnimatedStyle(() => ({ transform: [ { @@ -1074,6 +1088,35 @@ function AdaptiveWorkspaceLayoutContent( + {keyboardHoldsSidebar && contentTargetWidth !== null ? ( + <> + + + + ) : null}