From 7b5ef6c97c18f3f1740f1b9eb4e64a82b7a7bf3e Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 9 Nov 2026 20:22:19 -0800 Subject: [PATCH 01/13] fix(mobile): show Android side chats beside their parent --- .../layout/AdaptiveWorkspaceLayout.tsx | 18 +++++++--- .../layout/workspace-inspector-pane.tsx | 3 +- .../thread-inspector-navigation.test.tsx | 12 +++++-- .../threads/thread-inspector-navigation.ts | 3 +- apps/mobile/src/lib/layout.test.ts | 34 +++++++++++++++++++ apps/mobile/src/lib/layout.ts | 30 +++++++++++----- 6 files changed, 82 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 92d6be2d3309e..0fe718da116be 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -371,6 +371,7 @@ function AdaptiveWorkspaceLayoutContent( deriveFileInspectorPaneLayout({ layout, viewportWidth: width, + equalAuxiliarySplit: Platform.OS === "android", preferredWidth: fileInspectorPreferredWidth ?? undefined, reservedLeadingWidth: shouldRenderPrimarySidebar && showPrimarySidebar && !sidebarFloats @@ -406,6 +407,7 @@ function AdaptiveWorkspaceLayoutContent( deriveWorkspacePaneLayout({ layout, viewportWidth: width, + equalAuxiliarySplit: Platform.OS === "android", horizontalSafeArea, nativeWorkspace, primarySidebarFloats: sidebarFloats, @@ -803,20 +805,28 @@ function AdaptiveWorkspaceLayoutContent( scrimOpacity.set(withTiming(floatingSidebarOpen ? 0.32 : 0, WORKSPACE_PANE_TIMING)); }, [floatingSidebarOpen, scrimOpacity]); const scrimAnimatedStyle = useAnimatedStyle(() => ({ opacity: scrimOpacity.value })); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Back closes a floating sidebar before it leaves the page + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Back closes Android panes or a floating sidebar before leaving the page useEffect(() => { - if (!floatingSidebarOpen || nativeWorkspace) return; + const inspectorOpen = Platform.OS === "android" && panes.auxiliaryPaneVisible; + if ((!floatingSidebarOpen && !inspectorOpen) || nativeWorkspace) return; const subscription = BackHandler.addEventListener("hardwareBackPress", () => { // A sheet or modal over the workspace takes Back first. const state = rootStackState(navigation); if (state && workspaceRoutesOf(state.routes.slice(0, state.index + 1)).overlays.length > 0) { return false; } - setPrimarySidebarVisible(false); + if (inspectorOpen) setFileInspectorPreferredVisible(false); + else setPrimarySidebarVisible(false); return true; }); return () => subscription.remove(); - }, [floatingSidebarOpen, nativeWorkspace, navigation, setPrimarySidebarVisible]); + }, [ + floatingSidebarOpen, + panes.auxiliaryPaneVisible, + nativeWorkspace, + navigation, + setPrimarySidebarVisible, + ]); // 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; diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index c7a0e4cea1716..65aab531a1d03 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { Platform } from "react-native"; import Animated, { cubicBezier, runOnJS, @@ -97,7 +98,7 @@ export function WorkspaceInspectorPane(props: { return ( <> - {inspectorVisible ? ( + {inspectorVisible && Platform.OS !== "android" ? ( ({ dispatch: (_action: unknown) => {}, getState: (): unknown => undefined, getRoute: (): unknown => undefined, + supported: true, })); vi.mock("react-native", () => ({ Platform: { OS: "ios" } })); vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({ - useWorkspacePaneState: () => ({ fileInspector: { supported: true } }), + useWorkspacePaneState: () => ({ fileInspector: { supported: harness.supported } }), })); vi.mock("@react-navigation/native", async () => { const { createRequire } = await import("node:module"); @@ -38,7 +39,8 @@ const { StackRouter, StackActions } = requireNavigation("@react-navigation/route StackActions: typeof import("@react-navigation/native").StackActions; }; -function setup(fromAgents: boolean, fullscreen = false) { +function setup(fromAgents: boolean, fullscreen = false, supported = true) { + harness.supported = supported; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const router = StackRouter({}); const options = { @@ -297,3 +299,9 @@ it("keeps a focused inspector registered across renders and updates its content" expect(activeRender).toBeUndefined(); expect(listeners.focus.size + listeners.blur.size).toBe(0); }); + +it("keeps compact side chats on the route fallback", () => { + using screen = setup(false, false, false); + expect(screen.open({ type: "thread", ...screen.source, threadId: "child" })).toBe(false); + expect(screen.state.routes.at(-1)?.key).toBe(screen.parentKey); +}); diff --git a/apps/mobile/src/features/threads/thread-inspector-navigation.ts b/apps/mobile/src/features/threads/thread-inspector-navigation.ts index 4c929a46414ad..ef00083a36cfc 100644 --- a/apps/mobile/src/features/threads/thread-inspector-navigation.ts +++ b/apps/mobile/src/features/threads/thread-inspector-navigation.ts @@ -1,6 +1,5 @@ import { StackActions, useNavigation } from "@react-navigation/native"; import { createContext, use } from "react"; -import { Platform } from "react-native"; import { useWorkspacePaneState } from "../layout/AdaptiveWorkspaceLayout"; @@ -25,7 +24,7 @@ export function useOpenThreadInspector() { source: { readonly environmentId: string; readonly threadId: string }, target: ThreadInspectorTarget, ) => { - if (Platform.OS !== "ios" || !fileInspector.supported) return false; + if (!fileInspector.supported) return false; if (openInInspector) openInInspector(target); else { const parent = navigation.getState()?.routes.findLast((route) => route.name === "Thread"); diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 0115a7b3334d3..03b8f45d89d2f 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -140,6 +140,40 @@ describe("isPrimarySidebarPinned", () => { }); describe("deriveWorkspacePaneLayout", () => { + it.each([660, 745, 1100])( + "splits Android content equally and steps the sidebar aside at %s dp", + (viewportWidth) => { + const layout = deriveLayout({ width: viewportWidth + 96, height: 900 }); + for (const primarySidebarFloats of [false, true]) { + const input = { + layout, + viewportWidth, + equalAuxiliarySplit: true, + primarySidebarFloats, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + }; + expect(deriveWorkspacePaneLayout(input)).toMatchObject({ + primarySidebarVisible: false, + primarySidebarSuppressedByAuxiliary: true, + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: (viewportWidth - 4) / 2, + contentPaneWidth: viewportWidth, + }); + expect( + deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredVisible: false }), + ).toMatchObject({ primarySidebarVisible: true }); + expect( + deriveWorkspacePaneLayout({ + ...input, + layout: deriveLayout({ width: 390, height: 900 }), + viewportWidth: 390, + }), + ).toMatchObject({ auxiliaryPaneVisible: false }); + } + }, + ); + it.each([ { width: 1_194, height: 834, horizontalSafeArea: 0, fraction: 0.4, suppressed: false }, { width: 1_032, height: 1_376, horizontalSafeArea: 0, fraction: 0.4, suppressed: true }, diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index bc019ed8eb3f7..d51c290cc64ab 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -209,6 +209,7 @@ export function deriveWorkspacePaneLayout(input: { /** Width UIKit keeps out of its columns, such as the Duo's system bar. */ readonly horizontalSafeArea?: number; readonly nativeWorkspace?: boolean; + readonly equalAuxiliarySplit?: boolean; /** Whether the sidebar floats over the content rather than taking its room; medium by default. */ readonly primarySidebarFloats?: boolean; readonly primarySidebarPreferredVisible: boolean; @@ -229,6 +230,7 @@ export function deriveWorkspacePaneLayout(input: { layout: input.layout, viewportWidth, preferredWidth: input.auxiliaryPanePreferredWidth, + equalAuxiliarySplit: input.equalAuxiliarySplit, reservedLeadingWidth: preferredPrimarySidebarWidth, }); const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible; @@ -237,16 +239,19 @@ export function deriveWorkspacePaneLayout(input: { : fileInspector.width; const primarySidebarSuppressedByAuxiliary = preferredPrimarySidebarVisible && - !floats && auxiliaryPaneVisible && - inspectorWidth !== null && - input.layout.listPaneWidth !== null && - viewportWidth - - (input.nativeWorkspace ? (input.horizontalSafeArea ?? 0) : 0) - - input.layout.listPaneWidth - - (input.layout.listPaneGap ?? 0) - - inspectorWidth < - (input.nativeWorkspace ? NATIVE_INSPECTOR_MIN_MAIN_WIDTH : FILE_INSPECTOR_MIN_MAIN_WIDTH); + (input.equalAuxiliarySplit === true || + (!floats && + inspectorWidth !== null && + input.layout.listPaneWidth !== null && + viewportWidth - + (input.nativeWorkspace ? (input.horizontalSafeArea ?? 0) : 0) - + input.layout.listPaneWidth - + (input.layout.listPaneGap ?? 0) - + inspectorWidth < + (input.nativeWorkspace + ? NATIVE_INSPECTOR_MIN_MAIN_WIDTH + : FILE_INSPECTOR_MIN_MAIN_WIDTH))); const primarySidebarVisible = preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; const primarySidebarWidth = primarySidebarVisible ? preferredPrimarySidebarWidth : 0; @@ -284,8 +289,15 @@ export function deriveFileInspectorPaneLayout(input: { readonly viewportWidth: number; readonly preferredWidth?: number; readonly reservedLeadingWidth?: number; + readonly equalAuxiliarySplit?: boolean; }): FileInspectorPaneLayout { const viewportWidth = Math.max(0, input.viewportWidth); + if (input.equalAuxiliarySplit) { + // Android keeps a 4dp trailing card gap; the divider overlays the column boundary. + const width = Math.max(0, viewportWidth - 4) / 2; + const supported = input.layout.usesSplitView && width >= 320; + return { supported, width: supported ? width : null }; + } const reservedLeadingWidth = Number.isFinite(input.reservedLeadingWidth) ? Math.max(0, input.reservedLeadingWidth ?? 0) : 0; From a6801875a987ed70fd24231532d0ff1206f848f5 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 9 Nov 2026 20:29:57 -0800 Subject: [PATCH 02/13] fix(mobile): let Android inspector panes resize --- .../layout/AdaptiveWorkspaceLayout.tsx | 4 +-- .../layout/workspace-inspector-pane.tsx | 3 +- apps/mobile/src/lib/layout.test.ts | 16 ++++++++-- apps/mobile/src/lib/layout.ts | 29 ++++++++++++++----- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 0fe718da116be..29d377d63fcda 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -371,7 +371,7 @@ function AdaptiveWorkspaceLayoutContent( deriveFileInspectorPaneLayout({ layout, viewportWidth: width, - equalAuxiliarySplit: Platform.OS === "android", + balancedAuxiliarySplit: Platform.OS === "android", preferredWidth: fileInspectorPreferredWidth ?? undefined, reservedLeadingWidth: shouldRenderPrimarySidebar && showPrimarySidebar && !sidebarFloats @@ -407,7 +407,7 @@ function AdaptiveWorkspaceLayoutContent( deriveWorkspacePaneLayout({ layout, viewportWidth: width, - equalAuxiliarySplit: Platform.OS === "android", + balancedAuxiliarySplit: Platform.OS === "android", horizontalSafeArea, nativeWorkspace, primarySidebarFloats: sidebarFloats, diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index 65aab531a1d03..946d7b13a607a 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -89,6 +89,7 @@ export function WorkspaceInspectorPane(props: { constrainAuxiliaryPaneWidth({ preferredWidth: resizeStartWidth.current + delta, availableWidth: panes.contentPaneWidth, + balancedAuxiliarySplit: Platform.OS === "android", }), ); }; @@ -98,7 +99,7 @@ export function WorkspaceInspectorPane(props: { return ( <> - {inspectorVisible && Platform.OS !== "android" ? ( + {inspectorVisible ? ( { describe("deriveWorkspacePaneLayout", () => { it.each([660, 745, 1100])( - "splits Android content equally and steps the sidebar aside at %s dp", + "defaults Android content to equal panes and retains resized widths at %s dp", (viewportWidth) => { const layout = deriveLayout({ width: viewportWidth + 96, height: 900 }); for (const primarySidebarFloats of [false, true]) { const input = { layout, viewportWidth, - equalAuxiliarySplit: true, + balancedAuxiliarySplit: true, primarySidebarFloats, primarySidebarPreferredVisible: true, auxiliaryPanePreferredVisible: true, @@ -160,6 +161,17 @@ describe("deriveWorkspacePaneLayout", () => { auxiliaryPaneWidth: (viewportWidth - 4) / 2, contentPaneWidth: viewportWidth, }); + for (const preferredWidth of [320, 350, viewportWidth]) { + const width = constrainAuxiliaryPaneWidth({ + preferredWidth, + availableWidth: viewportWidth, + balancedAuxiliarySplit: true, + }); + expect(width).toBe(Math.min(preferredWidth, viewportWidth - 4 - 320)); + expect( + deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredWidth: width }), + ).toMatchObject({ auxiliaryPaneWidth: width, primarySidebarVisible: false }); + } expect( deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredVisible: false }), ).toMatchObject({ primarySidebarVisible: true }); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index d51c290cc64ab..b16ae548a26e7 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -209,7 +209,7 @@ export function deriveWorkspacePaneLayout(input: { /** Width UIKit keeps out of its columns, such as the Duo's system bar. */ readonly horizontalSafeArea?: number; readonly nativeWorkspace?: boolean; - readonly equalAuxiliarySplit?: boolean; + readonly balancedAuxiliarySplit?: boolean; /** Whether the sidebar floats over the content rather than taking its room; medium by default. */ readonly primarySidebarFloats?: boolean; readonly primarySidebarPreferredVisible: boolean; @@ -230,7 +230,7 @@ export function deriveWorkspacePaneLayout(input: { layout: input.layout, viewportWidth, preferredWidth: input.auxiliaryPanePreferredWidth, - equalAuxiliarySplit: input.equalAuxiliarySplit, + balancedAuxiliarySplit: input.balancedAuxiliarySplit, reservedLeadingWidth: preferredPrimarySidebarWidth, }); const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible; @@ -240,7 +240,7 @@ export function deriveWorkspacePaneLayout(input: { const primarySidebarSuppressedByAuxiliary = preferredPrimarySidebarVisible && auxiliaryPaneVisible && - (input.equalAuxiliarySplit === true || + (input.balancedAuxiliarySplit === true || (!floats && inspectorWidth !== null && input.layout.listPaneWidth !== null && @@ -289,14 +289,23 @@ export function deriveFileInspectorPaneLayout(input: { readonly viewportWidth: number; readonly preferredWidth?: number; readonly reservedLeadingWidth?: number; - readonly equalAuxiliarySplit?: boolean; + readonly balancedAuxiliarySplit?: boolean; }): FileInspectorPaneLayout { const viewportWidth = Math.max(0, input.viewportWidth); - if (input.equalAuxiliarySplit) { + if (input.balancedAuxiliarySplit) { // Android keeps a 4dp trailing card gap; the divider overlays the column boundary. - const width = Math.max(0, viewportWidth - 4) / 2; - const supported = input.layout.usesSplitView && width >= 320; - return { supported, width: supported ? width : null }; + const defaultWidth = Math.max(0, viewportWidth - 4) / 2; + const supported = input.layout.usesSplitView && defaultWidth >= 320; + return { + supported, + width: supported + ? constrainAuxiliaryPaneWidth({ + preferredWidth: input.preferredWidth ?? defaultWidth, + availableWidth: viewportWidth, + balancedAuxiliarySplit: true, + }) + : null, + }; } const reservedLeadingWidth = Number.isFinite(input.reservedLeadingWidth) ? Math.max(0, input.reservedLeadingWidth ?? 0) @@ -325,6 +334,7 @@ export function deriveFileInspectorPaneLayout(input: { export function constrainAuxiliaryPaneWidth(input: { readonly preferredWidth: number; readonly availableWidth: number; + readonly balancedAuxiliarySplit?: boolean; }): number { const safePreferredWidth = Number.isFinite(input.preferredWidth) ? input.preferredWidth @@ -332,6 +342,9 @@ export function constrainAuxiliaryPaneWidth(input: { const availableWidth = Number.isFinite(input.availableWidth) ? Math.max(0, input.availableWidth) : 0; + if (input.balancedAuxiliarySplit) { + return clamp(safePreferredWidth, 320, Math.max(320, availableWidth - 4 - 320)); + } const maxWidth = Math.max( AUXILIARY_PANE_MIN_WIDTH, Math.min(AUXILIARY_PANE_MAX_WIDTH, availableWidth - FILE_INSPECTOR_MIN_MAIN_WIDTH), From fd302b1cc7d92b788907fd8c5e730189aa9206f1 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 9 Nov 2026 20:42:16 -0800 Subject: [PATCH 03/13] fix(mobile): size Android inspectors by device form factor --- .../t3nativecontrols/T3FoldPostureModule.kt | 12 +++-- .../layout/AdaptiveWorkspaceLayout.tsx | 27 +++++++++--- .../layout/workspace-inspector-pane.tsx | 11 +---- apps/mobile/src/lib/layout.test.ts | 44 +++++++++++-------- apps/mobile/src/lib/layout.ts | 39 ++++++++++++---- .../native/AndroidHingeObserver.android.tsx | 16 ++++--- .../src/native/AndroidHingeObserver.tsx | 6 ++- 7 files changed, 101 insertions(+), 54 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt index c2169dabdc155..7b0e4b14490a6 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt @@ -44,6 +44,7 @@ class T3FoldPostureView( } else { null } + private var isFoldable = hingeSensor != null private var scope: CoroutineScope? = null private var fold: FoldingFeature? = null private var listening = false @@ -62,7 +63,9 @@ class T3FoldPostureView( scope = MainScope().also { scope -> scope.launch { WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity).collect { info -> - fold = info.displayFeatures.filterIsInstance().firstOrNull { + val features = info.displayFeatures.filterIsInstance() + isFoldable = isFoldable || features.isNotEmpty() + fold = features.firstOrNull { it.orientation == FoldingFeature.Orientation.VERTICAL } listen(fold != null) @@ -122,8 +125,9 @@ class T3FoldPostureView( } else { emptyMap() } - if (hinge == reported) return - reported = hinge - onHingeChange(hinge) + val state = hinge + ("isFoldable" to isFoldable) + if (state == reported) return + reported = state + onHingeChange(state) } } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 29d377d63fcda..09f934a752e7e 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -331,9 +331,15 @@ function AdaptiveWorkspaceLayoutContent( const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1, scale, fabSize); // The rail stays whether or not the sidebar shows; only the list pane comes and goes. const navigationRailWidth = shouldRenderNavigationRail ? ANDROID_WORKSPACE_RAIL_WIDTH : 0; + const [isFoldable, setIsFoldable] = useState(false); const [hinge, setHinge] = useState(null); const [windowHinge, setWindowHinge] = useState(null); - const handleHingeChange = (next: VerticalHinge | null, nextWindow: VerticalHinge | null) => { + const handleHingeChange = ( + next: VerticalHinge | null, + nextWindow: VerticalHinge | null, + foldable: boolean, + ) => { + setIsFoldable(foldable); setHinge(next); setWindowHinge(nextWindow); }; @@ -366,20 +372,26 @@ function AdaptiveWorkspaceLayoutContent( const width = viewportWidth - (sidebarFloats ? 0 : navigationRailWidth); // The sidebar stays mounted under a workspace page, so its list is ready on the way back. const shouldRenderPrimarySidebar = layout.usesSplitView; + const inspectorSizing = + Platform.OS === "android" ? (isFoldable ? "foldable" : "tablet") : undefined; + const auxiliaryPanePreferredWidth = + fileInspectorPreferredWidth ?? + (isFoldable ? viewportWidth - (windowHinge?.x ?? viewportWidth / 2) : undefined); const fileInspector = useMemo( () => deriveFileInspectorPaneLayout({ layout, viewportWidth: width, - balancedAuxiliarySplit: Platform.OS === "android", - preferredWidth: fileInspectorPreferredWidth ?? undefined, + inspectorSizing, + preferredWidth: auxiliaryPanePreferredWidth, reservedLeadingWidth: shouldRenderPrimarySidebar && showPrimarySidebar && !sidebarFloats ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, }), [ - fileInspectorPreferredWidth, + auxiliaryPanePreferredWidth, + inspectorSizing, layout, showPrimarySidebar, shouldRenderPrimarySidebar, @@ -407,7 +419,7 @@ function AdaptiveWorkspaceLayoutContent( deriveWorkspacePaneLayout({ layout, viewportWidth: width, - balancedAuxiliarySplit: Platform.OS === "android", + inspectorSizing, horizontalSafeArea, nativeWorkspace, primarySidebarFloats: sidebarFloats, @@ -415,14 +427,15 @@ function AdaptiveWorkspaceLayoutContent( // Only a column a screen fills makes room, so a stale preference can't hide the sidebar. auxiliaryPanePreferredVisible: !workspacePage && inspectorActive && fileInspectorPreferredVisible, - auxiliaryPanePreferredWidth: fileInspectorPreferredWidth ?? undefined, + auxiliaryPanePreferredWidth, nativeInspectorWidth, }), [ nativeInspectorWidth, horizontalSafeArea, fileInspectorPreferredVisible, - fileInspectorPreferredWidth, + auxiliaryPanePreferredWidth, + inspectorSizing, inspectorActive, layout, nativeWorkspace, diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index 946d7b13a607a..0e1f3aac2dd2e 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from "react"; -import { Platform } from "react-native"; import Animated, { cubicBezier, runOnJS, @@ -10,7 +9,7 @@ import Animated, { type SharedValue, } from "react-native-reanimated"; -import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout"; +import type { WorkspacePaneLayout } from "../../lib/layout"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspacePaneDivider } from "./workspace-pane-divider"; import { InspectorPanes } from "./inspector-host"; @@ -85,13 +84,7 @@ export function WorkspaceInspectorPane(props: { setResizing(true); }; const resizeBy = (delta: number) => { - setAuxiliaryPaneWidth( - constrainAuxiliaryPaneWidth({ - preferredWidth: resizeStartWidth.current + delta, - availableWidth: panes.contentPaneWidth, - balancedAuxiliarySplit: Platform.OS === "android", - }), - ); + setAuxiliaryPaneWidth(resizeStartWidth.current + delta); }; const endResize = () => { setResizing(false); diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index aa807a3db379c..e15d992a9d7b3 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -149,7 +149,7 @@ describe("deriveWorkspacePaneLayout", () => { const input = { layout, viewportWidth, - balancedAuxiliarySplit: true, + inspectorSizing: "foldable" as const, primarySidebarFloats, primarySidebarPreferredVisible: true, auxiliaryPanePreferredVisible: true, @@ -165,13 +165,16 @@ describe("deriveWorkspacePaneLayout", () => { const width = constrainAuxiliaryPaneWidth({ preferredWidth, availableWidth: viewportWidth, - balancedAuxiliarySplit: true, + inspectorSizing: "foldable" as const, }); expect(width).toBe(Math.min(preferredWidth, viewportWidth - 4 - 320)); expect( deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredWidth: width }), ).toMatchObject({ auxiliaryPaneWidth: width, primarySidebarVisible: false }); } + expect(deriveWorkspacePaneLayout({ ...input, inspectorSizing: "tablet" })).toMatchObject({ + auxiliaryPaneWidth: viewportWidth >= 820 ? viewportWidth * 0.4 : null, + }); expect( deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredVisible: false }), ).toMatchObject({ primarySidebarVisible: true }); @@ -195,26 +198,29 @@ describe("deriveWorkspacePaneLayout", () => { "steps the sidebar aside for the native inspector only when the page gets too narrow ($width wide)", ({ width, height, horizontalSafeArea, fraction, suppressed }) => { const layout = deriveLayout({ width, height }); - for (const primarySidebarPreferredVisible of [true, false]) { - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: width, - horizontalSafeArea, - nativeWorkspace: true, - primarySidebarPreferredVisible, - auxiliaryPanePreferredVisible: true, - nativeInspectorWidth: nativeInspectorColumnWidth({ + for (const nativeWorkspace of [true, false]) { + for (const primarySidebarPreferredVisible of [true, false]) { + expect( + deriveWorkspacePaneLayout({ + layout, viewportWidth: width, horizontalSafeArea, - fraction, + nativeWorkspace, + inspectorSizing: nativeWorkspace ? undefined : "tablet", + primarySidebarPreferredVisible, + auxiliaryPanePreferredVisible: true, + nativeInspectorWidth: nativeInspectorColumnWidth({ + viewportWidth: width, + horizontalSafeArea, + fraction, + }), }), - }), - ).toMatchObject({ - primarySidebarVisible: primarySidebarPreferredVisible && !suppressed, - primarySidebarSuppressedByAuxiliary: primarySidebarPreferredVisible && suppressed, - auxiliaryPaneVisible: true, - }); + ).toMatchObject({ + primarySidebarVisible: primarySidebarPreferredVisible && !suppressed, + primarySidebarSuppressedByAuxiliary: primarySidebarPreferredVisible && suppressed, + auxiliaryPaneVisible: true, + }); + } } }, ); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index b16ae548a26e7..6b6812a22465d 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -209,7 +209,7 @@ export function deriveWorkspacePaneLayout(input: { /** Width UIKit keeps out of its columns, such as the Duo's system bar. */ readonly horizontalSafeArea?: number; readonly nativeWorkspace?: boolean; - readonly balancedAuxiliarySplit?: boolean; + readonly inspectorSizing?: "foldable" | "tablet"; /** Whether the sidebar floats over the content rather than taking its room; medium by default. */ readonly primarySidebarFloats?: boolean; readonly primarySidebarPreferredVisible: boolean; @@ -230,7 +230,7 @@ export function deriveWorkspacePaneLayout(input: { layout: input.layout, viewportWidth, preferredWidth: input.auxiliaryPanePreferredWidth, - balancedAuxiliarySplit: input.balancedAuxiliarySplit, + inspectorSizing: input.inspectorSizing, reservedLeadingWidth: preferredPrimarySidebarWidth, }); const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible; @@ -240,7 +240,7 @@ export function deriveWorkspacePaneLayout(input: { const primarySidebarSuppressedByAuxiliary = preferredPrimarySidebarVisible && auxiliaryPaneVisible && - (input.balancedAuxiliarySplit === true || + (input.inspectorSizing === "foldable" || (!floats && inspectorWidth !== null && input.layout.listPaneWidth !== null && @@ -249,7 +249,7 @@ export function deriveWorkspacePaneLayout(input: { input.layout.listPaneWidth - (input.layout.listPaneGap ?? 0) - inspectorWidth < - (input.nativeWorkspace + (input.nativeWorkspace || input.inspectorSizing === "tablet" ? NATIVE_INSPECTOR_MIN_MAIN_WIDTH : FILE_INSPECTOR_MIN_MAIN_WIDTH))); const primarySidebarVisible = @@ -289,10 +289,10 @@ export function deriveFileInspectorPaneLayout(input: { readonly viewportWidth: number; readonly preferredWidth?: number; readonly reservedLeadingWidth?: number; - readonly balancedAuxiliarySplit?: boolean; + readonly inspectorSizing?: "foldable" | "tablet"; }): FileInspectorPaneLayout { const viewportWidth = Math.max(0, input.viewportWidth); - if (input.balancedAuxiliarySplit) { + if (input.inspectorSizing === "foldable") { // Android keeps a 4dp trailing card gap; the divider overlays the column boundary. const defaultWidth = Math.max(0, viewportWidth - 4) / 2; const supported = input.layout.usesSplitView && defaultWidth >= 320; @@ -302,7 +302,21 @@ export function deriveFileInspectorPaneLayout(input: { ? constrainAuxiliaryPaneWidth({ preferredWidth: input.preferredWidth ?? defaultWidth, availableWidth: viewportWidth, - balancedAuxiliarySplit: true, + inspectorSizing: "foldable", + }) + : null, + }; + } + if (input.inspectorSizing === "tablet") { + const supported = + input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH; + return { + supported, + width: supported + ? constrainAuxiliaryPaneWidth({ + preferredWidth: input.preferredWidth ?? viewportWidth * 0.4, + availableWidth: viewportWidth, + inspectorSizing: "tablet", }) : null, }; @@ -334,7 +348,7 @@ export function deriveFileInspectorPaneLayout(input: { export function constrainAuxiliaryPaneWidth(input: { readonly preferredWidth: number; readonly availableWidth: number; - readonly balancedAuxiliarySplit?: boolean; + readonly inspectorSizing?: "foldable" | "tablet"; }): number { const safePreferredWidth = Number.isFinite(input.preferredWidth) ? input.preferredWidth @@ -342,9 +356,16 @@ export function constrainAuxiliaryPaneWidth(input: { const availableWidth = Number.isFinite(input.availableWidth) ? Math.max(0, input.availableWidth) : 0; - if (input.balancedAuxiliarySplit) { + if (input.inspectorSizing === "foldable") { return clamp(safePreferredWidth, 320, Math.max(320, availableWidth - 4 - 320)); } + if (input.inspectorSizing === "tablet") { + return nativeInspectorColumnWidth({ + viewportWidth: availableWidth, + horizontalSafeArea: 4, + fraction: availableWidth > 0 ? safePreferredWidth / availableWidth : 0.4, + }); + } const maxWidth = Math.max( AUXILIARY_PANE_MIN_WIDTH, Math.min(AUXILIARY_PANE_MAX_WIDTH, availableWidth - FILE_INSPECTOR_MIN_MAIN_WIDTH), diff --git a/apps/mobile/src/native/AndroidHingeObserver.android.tsx b/apps/mobile/src/native/AndroidHingeObserver.android.tsx index 39a1594b536d2..a82390413cec9 100644 --- a/apps/mobile/src/native/AndroidHingeObserver.android.tsx +++ b/apps/mobile/src/native/AndroidHingeObserver.android.tsx @@ -4,7 +4,9 @@ import type { VerticalHinge } from "../lib/layout"; interface ObserverProps extends ViewProps { readonly onHingeChange: ( - event: NativeSyntheticEvent>, + event: NativeSyntheticEvent< + Partial + >, ) => void; } @@ -13,15 +15,19 @@ const Observer = requireNativeView("T3FoldPosture"); /** Reports a vertical hinge in this view's coordinates while the device is bent like a book. */ export function AndroidHingeObserver(props: { /** Receives the hinge in this view's coordinates and in window coordinates. */ - readonly onChange: (hinge: VerticalHinge | null, windowHinge: VerticalHinge | null) => void; + readonly onChange: ( + hinge: VerticalHinge | null, + windowHinge: VerticalHinge | null, + isFoldable: boolean, + ) => void; }) { return ( + onHingeChange={({ nativeEvent: { x, width, windowX, isFoldable } }) => x === undefined || width === undefined || windowX === undefined - ? props.onChange(null, null) - : props.onChange({ x, width }, { x: windowX, width }) + ? props.onChange(null, null, isFoldable === true) + : props.onChange({ x, width }, { x: windowX, width }, isFoldable === true) } style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }} /> diff --git a/apps/mobile/src/native/AndroidHingeObserver.tsx b/apps/mobile/src/native/AndroidHingeObserver.tsx index 164ce6211a51f..cddcf6c526b61 100644 --- a/apps/mobile/src/native/AndroidHingeObserver.tsx +++ b/apps/mobile/src/native/AndroidHingeObserver.tsx @@ -1,7 +1,11 @@ import type { VerticalHinge } from "../lib/layout"; export function AndroidHingeObserver(_props: { - readonly onChange: (hinge: VerticalHinge | null, windowHinge: VerticalHinge | null) => void; + readonly onChange: ( + hinge: VerticalHinge | null, + windowHinge: VerticalHinge | null, + isFoldable: boolean, + ) => void; }) { return null; } From 3b0aa52a75aa9166a2bc300de59492e41dad5e5a Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 9 Nov 2026 21:10:29 -0800 Subject: [PATCH 04/13] test(mobile): keep inspector coverage focused on the Android regression --- .../threads/thread-inspector-navigation.test.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx b/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx index b61d409d66c7b..d09bd106941c0 100644 --- a/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx @@ -9,12 +9,11 @@ const harness = vi.hoisted(() => ({ dispatch: (_action: unknown) => {}, getState: (): unknown => undefined, getRoute: (): unknown => undefined, - supported: true, })); vi.mock("react-native", () => ({ Platform: { OS: "ios" } })); vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({ - useWorkspacePaneState: () => ({ fileInspector: { supported: harness.supported } }), + useWorkspacePaneState: () => ({ fileInspector: { supported: true } }), })); vi.mock("@react-navigation/native", async () => { const { createRequire } = await import("node:module"); @@ -39,8 +38,7 @@ const { StackRouter, StackActions } = requireNavigation("@react-navigation/route StackActions: typeof import("@react-navigation/native").StackActions; }; -function setup(fromAgents: boolean, fullscreen = false, supported = true) { - harness.supported = supported; +function setup(fromAgents: boolean, fullscreen = false) { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const router = StackRouter({}); const options = { @@ -299,9 +297,3 @@ it("keeps a focused inspector registered across renders and updates its content" expect(activeRender).toBeUndefined(); expect(listeners.focus.size + listeners.blur.size).toBe(0); }); - -it("keeps compact side chats on the route fallback", () => { - using screen = setup(false, false, false); - expect(screen.open({ type: "thread", ...screen.source, threadId: "child" })).toBe(false); - expect(screen.state.routes.at(-1)?.key).toBe(screen.parentKey); -}); From 0cf26b8109810fb0318bc53632862d64f20fde96 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 9 Nov 2026 21:38:43 -0800 Subject: [PATCH 05/13] fix(mobile): keep portrait tablet side chats beside the parent --- .../layout/AdaptiveWorkspaceLayout.tsx | 18 ++++++++++++++++-- apps/mobile/src/lib/layout.test.ts | 6 ++++-- apps/mobile/src/lib/layout.ts | 3 ++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 09f934a752e7e..4fdbe27b0ca59 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -929,6 +929,20 @@ function AdaptiveWorkspaceLayoutContent( [sidebarInLandscape, sidebarInPortrait, sidebarOverRotates], ); + const setAuxiliaryPaneWidth = (preferredWidth: number) => { + const next = deriveFileInspectorPaneLayout({ + layout, + viewportWidth: width, + inspectorSizing, + preferredWidth, + reservedLeadingWidth: + shouldRenderPrimarySidebar && showPrimarySidebar && !sidebarFloats + ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) + : 0, + }); + if (next.width !== null) setFileInspectorPreferredWidth(next.width); + }; + const actions = useStableActions({ startNewTask: handleStartNewTask, selectDetail: handleSelectDetail, @@ -940,7 +954,7 @@ function AdaptiveWorkspaceLayoutContent( revealPrimarySidebar, setPrimarySidebarVisible, openWorkspaceTab, - setAuxiliaryPaneWidth: setFileInspectorPreferredWidth, + setAuxiliaryPaneWidth, setNativeInspectorWidth: (width: number) => setNativeInspectorWidthFraction(inspectorWidthFor(width / viewportWidth) / viewportWidth), }); @@ -1141,7 +1155,7 @@ function AdaptiveWorkspaceLayoutContent( active={workspaceInspector?.active ?? false} panes={panes} content={workspaceInspector?.content} - setAuxiliaryPaneWidth={setFileInspectorPreferredWidth} + setAuxiliaryPaneWidth={setAuxiliaryPaneWidth} onClose={() => setFileInspectorPreferredVisible(false)} onClosed={handleWorkspaceInspectorClosed} /> diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index e15d992a9d7b3..05f74d38a8a0f 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -141,7 +141,7 @@ describe("isPrimarySidebarPinned", () => { }); describe("deriveWorkspacePaneLayout", () => { - it.each([660, 745, 1100])( + it.each([660, 744, 745, 1100])( "defaults Android content to equal panes and retains resized widths at %s dp", (viewportWidth) => { const layout = deriveLayout({ width: viewportWidth + 96, height: 900 }); @@ -173,7 +173,9 @@ describe("deriveWorkspacePaneLayout", () => { ).toMatchObject({ auxiliaryPaneWidth: width, primarySidebarVisible: false }); } expect(deriveWorkspacePaneLayout({ ...input, inspectorSizing: "tablet" })).toMatchObject({ - auxiliaryPaneWidth: viewportWidth >= 820 ? viewportWidth * 0.4 : null, + auxiliaryPaneWidth: viewportWidth * 0.4, + auxiliaryPaneVisible: true, + primarySidebarVisible: primarySidebarFloats, }); expect( deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredVisible: false }), diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 6b6812a22465d..d3cc6422f59ef 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -309,7 +309,8 @@ export function deriveFileInspectorPaneLayout(input: { } if (input.inspectorSizing === "tablet") { const supported = - input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH; + input.layout.usesSplitView && + viewportWidth >= AUXILIARY_PANE_MIN_WIDTH + NATIVE_INSPECTOR_MIN_MAIN_WIDTH + 4; return { supported, width: supported From 7e97907669e04277380adfaeeb0f52cf00c031fc Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 9 Nov 2026 21:42:19 -0800 Subject: [PATCH 06/13] fix(mobile): match Android inspector and sidebar gutters --- apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 4fdbe27b0ca59..94a9b0d6835f8 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -643,8 +643,9 @@ function AdaptiveWorkspaceLayoutContent( const inspectorColumnTargetWidth = panes.auxiliaryPaneVisible ? (panes.auxiliaryPaneWidth ?? 0) : 0; - // Android's split content is a rounded card: keep it off the screen edge like the list card. - const contentEndGap = Platform.OS === "android" && layout.usesSplitView ? 4 : 0; + // ScreenContent supplies both margins at an inspector boundary. + const contentEndGap = + Platform.OS === "android" && layout.usesSplitView && !panes.auxiliaryPaneVisible ? 4 : 0; // Android's split is one strip: a sidebar slot of fixed width, then the detail pane. The // sidebar never resizes; both slide by one offset, so together they always fill the width. const stripSidebar = From e04cd86479f0b86fb125a8636ad9b420f1172f2b Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 9 Nov 2026 22:31:12 -0800 Subject: [PATCH 07/13] fix(mobile): keep Fold inspector bounds aligned with the physical split --- .../layout/AdaptiveWorkspaceLayout.tsx | 24 +++++++-- apps/mobile/src/lib/layout.test.ts | 51 +++++++++++++++++++ apps/mobile/src/lib/layout.ts | 32 ++++++++++-- 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 94a9b0d6835f8..ab9db738d9441 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -374,15 +374,21 @@ function AdaptiveWorkspaceLayoutContent( const shouldRenderPrimarySidebar = layout.usesSplitView; const inspectorSizing = Platform.OS === "android" ? (isFoldable ? "foldable" : "tablet") : undefined; - const auxiliaryPanePreferredWidth = - fileInspectorPreferredWidth ?? - (isFoldable ? viewportWidth - (windowHinge?.x ?? viewportWidth / 2) : undefined); + const auxiliaryPanePreferredWidth = fileInspectorPreferredWidth ?? undefined; + const foldableWindow = useMemo( + () => + isFoldable + ? { width: viewportWidth, leadingWidth: viewportWidth - width, hinge: windowHinge } + : undefined, + [isFoldable, viewportWidth, width, windowHinge], + ); const fileInspector = useMemo( () => deriveFileInspectorPaneLayout({ layout, viewportWidth: width, inspectorSizing, + foldableWindow, preferredWidth: auxiliaryPanePreferredWidth, reservedLeadingWidth: shouldRenderPrimarySidebar && showPrimarySidebar && !sidebarFloats @@ -392,6 +398,7 @@ function AdaptiveWorkspaceLayoutContent( [ auxiliaryPanePreferredWidth, inspectorSizing, + foldableWindow, layout, showPrimarySidebar, shouldRenderPrimarySidebar, @@ -420,6 +427,7 @@ function AdaptiveWorkspaceLayoutContent( layout, viewportWidth: width, inspectorSizing, + foldableWindow, horizontalSafeArea, nativeWorkspace, primarySidebarFloats: sidebarFloats, @@ -436,6 +444,7 @@ function AdaptiveWorkspaceLayoutContent( fileInspectorPreferredVisible, auxiliaryPanePreferredWidth, inspectorSizing, + foldableWindow, inspectorActive, layout, nativeWorkspace, @@ -643,6 +652,7 @@ function AdaptiveWorkspaceLayoutContent( const inspectorColumnTargetWidth = panes.auxiliaryPaneVisible ? (panes.auxiliaryPaneWidth ?? 0) : 0; + const inspectorGap = panes.auxiliaryPaneGap ?? 0; // ScreenContent supplies both margins at an inspector boundary. const contentEndGap = Platform.OS === "android" && layout.usesSplitView && !panes.auxiliaryPaneVisible ? 4 : 0; @@ -664,6 +674,7 @@ function AdaptiveWorkspaceLayoutContent( 0, (contentSpansSidebar ? width : panes.contentPaneWidth) - inspectorColumnTargetWidth - + inspectorGap - contentEndGap, ); // A workspace page (Usage) lays out at this width even while hidden behind another tab, so @@ -876,7 +887,8 @@ function AdaptiveWorkspaceLayoutContent( viewportWidth - (sidebarFloats ? 0 : renderedRailWidth.value) - (workspacePage || sidebarFloats ? 0 : renderedSidebarWidth.value) - - renderedInspectorWidth.value, + renderedInspectorWidth.value - + inspectorGap, ), ); // Android can resize the native row before React removes its split panes on a fold. @@ -893,7 +905,7 @@ function AdaptiveWorkspaceLayoutContent( // back to the row, so a column after it stays put while the strip slides. width: Math.max( 0, - viewportWidth - renderedRailWidth.value - renderedInspectorWidth.value, + viewportWidth - renderedRailWidth.value - renderedInspectorWidth.value - inspectorGap, ), marginRight: -sidebarSlotWidth, transform: [{ translateX: stripOffset.value }], @@ -935,6 +947,7 @@ function AdaptiveWorkspaceLayoutContent( layout, viewportWidth: width, inspectorSizing, + foldableWindow, preferredWidth, reservedLeadingWidth: shouldRenderPrimarySidebar && showPrimarySidebar && !sidebarFloats @@ -1150,6 +1163,7 @@ function AdaptiveWorkspaceLayoutContent( /> ) : null} + {inspectorGap > 0 ? : null} { }); describe("deriveWorkspacePaneLayout", () => { + it.each([ + { width: 744, leadingWidth: 72, hinge: null, inspectorWidth: 372, maximumWidth: 372, gap: 0 }, + { + width: 900, + leadingWidth: 72, + hinge: { x: 450, width: 0 }, + inspectorWidth: 450, + maximumWidth: 508, + gap: 0, + }, + { + width: 744, + leadingWidth: 72, + hinge: { x: 372, width: 8 }, + inspectorWidth: 364, + maximumWidth: 372, + gap: 8, + }, + ])( + "keeps the default Fold panes on either side of the physical split ($width dp)", + ({ width, leadingWidth, hinge, inspectorWidth, maximumWidth, gap }) => { + const input = { + layout: deriveLayout({ width, height: 900 }), + viewportWidth: width - leadingWidth, + inspectorSizing: "foldable" as const, + foldableWindow: { width, leadingWidth, hinge }, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + }; + expect(deriveWorkspacePaneLayout(input)).toMatchObject({ + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: inspectorWidth, + auxiliaryPaneGap: gap, + primarySidebarVisible: false, + }); + expect(width - inspectorWidth - gap).toBe(hinge?.x ?? width / 2); + expect( + deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredWidth: width }), + ).toMatchObject({ + auxiliaryPaneWidth: maximumWidth, + auxiliaryPaneGap: 0, + }); + expect( + deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredWidth: 320 }), + ).toMatchObject({ + auxiliaryPaneWidth: 320, + auxiliaryPaneGap: 0, + }); + }, + ); + it.each([660, 744, 745, 1100])( "defaults Android content to equal panes and retains resized widths at %s dp", (viewportWidth) => { diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index d3cc6422f59ef..79a21652caf56 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -86,9 +86,17 @@ export interface WorkspacePaneLayout { readonly supportsAuxiliaryPane: boolean; readonly auxiliaryPaneVisible: boolean; readonly auxiliaryPaneWidth: number | null; + readonly auxiliaryPaneGap?: number; +} + +export interface FoldableWindow { + readonly width: number; + readonly leadingWidth: number; + readonly hinge: VerticalHinge | null; } export interface FileInspectorPaneLayout { + readonly gap?: number; readonly supported: boolean; readonly width: number | null; } @@ -210,6 +218,7 @@ export function deriveWorkspacePaneLayout(input: { readonly horizontalSafeArea?: number; readonly nativeWorkspace?: boolean; readonly inspectorSizing?: "foldable" | "tablet"; + readonly foldableWindow?: FoldableWindow; /** Whether the sidebar floats over the content rather than taking its room; medium by default. */ readonly primarySidebarFloats?: boolean; readonly primarySidebarPreferredVisible: boolean; @@ -231,6 +240,7 @@ export function deriveWorkspacePaneLayout(input: { viewportWidth, preferredWidth: input.auxiliaryPanePreferredWidth, inspectorSizing: input.inspectorSizing, + foldableWindow: input.foldableWindow, reservedLeadingWidth: preferredPrimarySidebarWidth, }); const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible; @@ -261,6 +271,7 @@ export function deriveWorkspacePaneLayout(input: { primarySidebarWhenExpanded: input.primarySidebarPreferredVisible, primarySidebarSuppressedByAuxiliary, contentPaneWidth: Math.max(0, viewportWidth - primarySidebarWidth), + auxiliaryPaneGap: auxiliaryPaneVisible ? (fileInspector.gap ?? 0) : 0, supportsAuxiliaryPane: fileInspector.supported, auxiliaryPaneVisible, auxiliaryPaneWidth: fileInspector.width, @@ -290,19 +301,25 @@ export function deriveFileInspectorPaneLayout(input: { readonly preferredWidth?: number; readonly reservedLeadingWidth?: number; readonly inspectorSizing?: "foldable" | "tablet"; + readonly foldableWindow?: FoldableWindow; }): FileInspectorPaneLayout { const viewportWidth = Math.max(0, input.viewportWidth); if (input.inspectorSizing === "foldable") { - // Android keeps a 4dp trailing card gap; the divider overlays the column boundary. - const defaultWidth = Math.max(0, viewportWidth - 4) / 2; - const supported = input.layout.usesSplitView && defaultWidth >= 320; + const window = input.foldableWindow; + const gap = input.preferredWidth === undefined ? (window?.hinge?.width ?? 0) : 0; + const defaultWidth = window + ? window.width - (window.hinge?.x ?? window.width / 2) - gap + : Math.max(0, viewportWidth - 4) / 2; + const supported = input.layout.usesSplitView && viewportWidth - gap >= 640; return { supported, + gap: supported ? gap : 0, width: supported ? constrainAuxiliaryPaneWidth({ preferredWidth: input.preferredWidth ?? defaultWidth, - availableWidth: viewportWidth, + availableWidth: viewportWidth - gap, inspectorSizing: "foldable", + foldableWindow: window, }) : null, }; @@ -350,6 +367,7 @@ export function constrainAuxiliaryPaneWidth(input: { readonly preferredWidth: number; readonly availableWidth: number; readonly inspectorSizing?: "foldable" | "tablet"; + readonly foldableWindow?: FoldableWindow; }): number { const safePreferredWidth = Number.isFinite(input.preferredWidth) ? input.preferredWidth @@ -358,7 +376,11 @@ export function constrainAuxiliaryPaneWidth(input: { ? Math.max(0, input.availableWidth) : 0; if (input.inspectorSizing === "foldable") { - return clamp(safePreferredWidth, 320, Math.max(320, availableWidth - 4 - 320)); + const window = input.foldableWindow; + const mainReserve = window + ? Math.min(320, (window.hinge?.x ?? window.width / 2) - window.leadingWidth) + : 324; + return clamp(safePreferredWidth, 320, Math.max(320, availableWidth - mainReserve)); } if (input.inspectorSizing === "tablet") { return nativeInspectorColumnWidth({ From 3cb3d2662df3656c2c7ec3058c3d6bd985d32679 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 9 Nov 2026 22:47:32 -0800 Subject: [PATCH 08/13] fix(mobile): size Android inspectors by window width --- .../t3nativecontrols/T3FoldPostureModule.kt | 12 +-- .../layout/AdaptiveWorkspaceLayout.tsx | 27 +++--- apps/mobile/src/lib/layout.test.ts | 84 ++++++++++++++----- apps/mobile/src/lib/layout.ts | 51 ++++++----- .../native/AndroidHingeObserver.android.tsx | 16 ++-- .../src/native/AndroidHingeObserver.tsx | 6 +- 6 files changed, 113 insertions(+), 83 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt index 7b0e4b14490a6..c2169dabdc155 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt @@ -44,7 +44,6 @@ class T3FoldPostureView( } else { null } - private var isFoldable = hingeSensor != null private var scope: CoroutineScope? = null private var fold: FoldingFeature? = null private var listening = false @@ -63,9 +62,7 @@ class T3FoldPostureView( scope = MainScope().also { scope -> scope.launch { WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity).collect { info -> - val features = info.displayFeatures.filterIsInstance() - isFoldable = isFoldable || features.isNotEmpty() - fold = features.firstOrNull { + fold = info.displayFeatures.filterIsInstance().firstOrNull { it.orientation == FoldingFeature.Orientation.VERTICAL } listen(fold != null) @@ -125,9 +122,8 @@ class T3FoldPostureView( } else { emptyMap() } - val state = hinge + ("isFoldable" to isFoldable) - if (state == reported) return - reported = state - onHingeChange(state) + if (hinge == reported) return + reported = hinge + onHingeChange(hinge) } } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index ab9db738d9441..873aecbc5a665 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -331,15 +331,9 @@ function AdaptiveWorkspaceLayoutContent( const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1, scale, fabSize); // The rail stays whether or not the sidebar shows; only the list pane comes and goes. const navigationRailWidth = shouldRenderNavigationRail ? ANDROID_WORKSPACE_RAIL_WIDTH : 0; - const [isFoldable, setIsFoldable] = useState(false); const [hinge, setHinge] = useState(null); const [windowHinge, setWindowHinge] = useState(null); - const handleHingeChange = ( - next: VerticalHinge | null, - nextWindow: VerticalHinge | null, - foldable: boolean, - ) => { - setIsFoldable(foldable); + const handleHingeChange = (next: VerticalHinge | null, nextWindow: VerticalHinge | null) => { setHinge(next); setWindowHinge(nextWindow); }; @@ -372,15 +366,14 @@ function AdaptiveWorkspaceLayoutContent( const width = viewportWidth - (sidebarFloats ? 0 : navigationRailWidth); // The sidebar stays mounted under a workspace page, so its list is ready on the way back. const shouldRenderPrimarySidebar = layout.usesSplitView; - const inspectorSizing = - Platform.OS === "android" ? (isFoldable ? "foldable" : "tablet") : undefined; + const inspectorSizing = Platform.OS === "android" ? "android" : undefined; const auxiliaryPanePreferredWidth = fileInspectorPreferredWidth ?? undefined; - const foldableWindow = useMemo( + const inspectorWindow = useMemo( () => - isFoldable + Platform.OS === "android" ? { width: viewportWidth, leadingWidth: viewportWidth - width, hinge: windowHinge } : undefined, - [isFoldable, viewportWidth, width, windowHinge], + [viewportWidth, width, windowHinge], ); const fileInspector = useMemo( () => @@ -388,7 +381,7 @@ function AdaptiveWorkspaceLayoutContent( layout, viewportWidth: width, inspectorSizing, - foldableWindow, + inspectorWindow, preferredWidth: auxiliaryPanePreferredWidth, reservedLeadingWidth: shouldRenderPrimarySidebar && showPrimarySidebar && !sidebarFloats @@ -398,7 +391,7 @@ function AdaptiveWorkspaceLayoutContent( [ auxiliaryPanePreferredWidth, inspectorSizing, - foldableWindow, + inspectorWindow, layout, showPrimarySidebar, shouldRenderPrimarySidebar, @@ -427,7 +420,7 @@ function AdaptiveWorkspaceLayoutContent( layout, viewportWidth: width, inspectorSizing, - foldableWindow, + inspectorWindow, horizontalSafeArea, nativeWorkspace, primarySidebarFloats: sidebarFloats, @@ -444,7 +437,7 @@ function AdaptiveWorkspaceLayoutContent( fileInspectorPreferredVisible, auxiliaryPanePreferredWidth, inspectorSizing, - foldableWindow, + inspectorWindow, inspectorActive, layout, nativeWorkspace, @@ -947,7 +940,7 @@ function AdaptiveWorkspaceLayoutContent( layout, viewportWidth: width, inspectorSizing, - foldableWindow, + inspectorWindow, preferredWidth, reservedLeadingWidth: shouldRenderPrimarySidebar && showPrimarySidebar && !sidebarFloats diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 9015e07ffca58..8be7c4391aaec 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -142,13 +142,40 @@ describe("isPrimarySidebarPinned", () => { describe("deriveWorkspacePaneLayout", () => { it.each([ - { width: 744, leadingWidth: 72, hinge: null, inspectorWidth: 372, maximumWidth: 372, gap: 0 }, + { + width: 704, + leadingWidth: 72, + hinge: null, + inspectorWidth: 352, + maximumWidth: 352, + mainEnd: 352, + gap: 0, + }, + { + width: 933, + leadingWidth: 72, + hinge: null, + inspectorWidth: 466.5, + maximumWidth: 541, + mainEnd: 466.5, + gap: 0, + }, + { + width: 744, + leadingWidth: 72, + hinge: null, + inspectorWidth: 372, + maximumWidth: 372, + mainEnd: 372, + gap: 0, + }, { width: 900, leadingWidth: 72, hinge: { x: 450, width: 0 }, inspectorWidth: 450, maximumWidth: 508, + mainEnd: 450, gap: 0, }, { @@ -157,30 +184,40 @@ describe("deriveWorkspacePaneLayout", () => { hinge: { x: 372, width: 8 }, inspectorWidth: 364, maximumWidth: 372, + mainEnd: 372, gap: 8, }, + { + width: 1000, + leadingWidth: 72, + hinge: null, + inspectorWidth: 371.2, + maximumWidth: 556.8, + mainEnd: 628.8, + gap: 0, + }, ])( - "keeps the default Fold panes on either side of the physical split ($width dp)", - ({ width, leadingWidth, hinge, inspectorWidth, maximumWidth, gap }) => { + "derives the inspector from window width and existing hinge coordinates ($width dp)", + ({ width, leadingWidth, hinge, inspectorWidth, maximumWidth, mainEnd, gap }) => { const input = { layout: deriveLayout({ width, height: 900 }), viewportWidth: width - leadingWidth, - inspectorSizing: "foldable" as const, - foldableWindow: { width, leadingWidth, hinge }, + inspectorSizing: "android" as const, + inspectorWindow: { width, leadingWidth, hinge }, primarySidebarPreferredVisible: true, auxiliaryPanePreferredVisible: true, }; expect(deriveWorkspacePaneLayout(input)).toMatchObject({ auxiliaryPaneVisible: true, - auxiliaryPaneWidth: inspectorWidth, + auxiliaryPaneWidth: expect.closeTo(inspectorWidth), auxiliaryPaneGap: gap, primarySidebarVisible: false, }); - expect(width - inspectorWidth - gap).toBe(hinge?.x ?? width / 2); + expect(width - inspectorWidth - gap).toBeCloseTo(mainEnd); expect( deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredWidth: width }), ).toMatchObject({ - auxiliaryPaneWidth: maximumWidth, + auxiliaryPaneWidth: expect.closeTo(maximumWidth), auxiliaryPaneGap: 0, }); expect( @@ -192,15 +229,20 @@ describe("deriveWorkspacePaneLayout", () => { }, ); - it.each([660, 744, 745, 1100])( - "defaults Android content to equal panes and retains resized widths at %s dp", - (viewportWidth) => { + it.each([ + { viewportWidth: 660, defaultWidth: 328, maximumWidth: 336 }, + { viewportWidth: 744, defaultWidth: 370, maximumWidth: 420 }, + { viewportWidth: 745, defaultWidth: 370.5, maximumWidth: 421 }, + { viewportWidth: 1100, defaultWidth: 440, maximumWidth: 660 }, + ])( + "sizes Android panes from the available width at $viewportWidth dp", + ({ viewportWidth, defaultWidth, maximumWidth }) => { const layout = deriveLayout({ width: viewportWidth + 96, height: 900 }); for (const primarySidebarFloats of [false, true]) { const input = { layout, viewportWidth, - inspectorSizing: "foldable" as const, + inspectorSizing: "android" as const, primarySidebarFloats, primarySidebarPreferredVisible: true, auxiliaryPanePreferredVisible: true, @@ -209,25 +251,23 @@ describe("deriveWorkspacePaneLayout", () => { primarySidebarVisible: false, primarySidebarSuppressedByAuxiliary: true, auxiliaryPaneVisible: true, - auxiliaryPaneWidth: (viewportWidth - 4) / 2, + auxiliaryPaneWidth: defaultWidth, contentPaneWidth: viewportWidth, }); for (const preferredWidth of [320, 350, viewportWidth]) { const width = constrainAuxiliaryPaneWidth({ preferredWidth, availableWidth: viewportWidth, - inspectorSizing: "foldable" as const, + inspectorSizing: "android" as const, }); - expect(width).toBe(Math.min(preferredWidth, viewportWidth - 4 - 320)); + expect(width).toBe(Math.min(preferredWidth, maximumWidth)); expect( deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredWidth: width }), - ).toMatchObject({ auxiliaryPaneWidth: width, primarySidebarVisible: false }); + ).toMatchObject({ + auxiliaryPaneWidth: width, + primarySidebarVisible: !primarySidebarFloats && viewportWidth === 1100 && width <= 400, + }); } - expect(deriveWorkspacePaneLayout({ ...input, inspectorSizing: "tablet" })).toMatchObject({ - auxiliaryPaneWidth: viewportWidth * 0.4, - auxiliaryPaneVisible: true, - primarySidebarVisible: primarySidebarFloats, - }); expect( deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredVisible: false }), ).toMatchObject({ primarySidebarVisible: true }); @@ -259,7 +299,7 @@ describe("deriveWorkspacePaneLayout", () => { viewportWidth: width, horizontalSafeArea, nativeWorkspace, - inspectorSizing: nativeWorkspace ? undefined : "tablet", + inspectorSizing: nativeWorkspace ? undefined : "android", primarySidebarPreferredVisible, auxiliaryPanePreferredVisible: true, nativeInspectorWidth: nativeInspectorColumnWidth({ diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 79a21652caf56..b15f225a1fcb7 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -57,6 +57,7 @@ const AUXILIARY_PANE_MIN_WIDTH = 260; const AUXILIARY_PANE_MAX_WIDTH = 480; const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320; const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; +const ANDROID_WIDE_INSPECTOR_MIN_WIDTH = 1000; const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; // UIKit's inspector column keeps its own width, so the sidebar steps aside only when the page // would get narrower than this: landscape 11" and 13" iPads keep three columns, portrait ones don't. @@ -89,7 +90,7 @@ export interface WorkspacePaneLayout { readonly auxiliaryPaneGap?: number; } -export interface FoldableWindow { +export interface InspectorWindow { readonly width: number; readonly leadingWidth: number; readonly hinge: VerticalHinge | null; @@ -217,8 +218,8 @@ export function deriveWorkspacePaneLayout(input: { /** Width UIKit keeps out of its columns, such as the Duo's system bar. */ readonly horizontalSafeArea?: number; readonly nativeWorkspace?: boolean; - readonly inspectorSizing?: "foldable" | "tablet"; - readonly foldableWindow?: FoldableWindow; + readonly inspectorSizing?: "android"; + readonly inspectorWindow?: InspectorWindow; /** Whether the sidebar floats over the content rather than taking its room; medium by default. */ readonly primarySidebarFloats?: boolean; readonly primarySidebarPreferredVisible: boolean; @@ -240,7 +241,7 @@ export function deriveWorkspacePaneLayout(input: { viewportWidth, preferredWidth: input.auxiliaryPanePreferredWidth, inspectorSizing: input.inspectorSizing, - foldableWindow: input.foldableWindow, + inspectorWindow: input.inspectorWindow, reservedLeadingWidth: preferredPrimarySidebarWidth, }); const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible; @@ -250,7 +251,7 @@ export function deriveWorkspacePaneLayout(input: { const primarySidebarSuppressedByAuxiliary = preferredPrimarySidebarVisible && auxiliaryPaneVisible && - (input.inspectorSizing === "foldable" || + ((input.inspectorSizing === "android" && floats) || (!floats && inspectorWidth !== null && input.layout.listPaneWidth !== null && @@ -259,7 +260,7 @@ export function deriveWorkspacePaneLayout(input: { input.layout.listPaneWidth - (input.layout.listPaneGap ?? 0) - inspectorWidth < - (input.nativeWorkspace || input.inspectorSizing === "tablet" + (input.nativeWorkspace || input.inspectorSizing === "android" ? NATIVE_INSPECTOR_MIN_MAIN_WIDTH : FILE_INSPECTOR_MIN_MAIN_WIDTH))); const primarySidebarVisible = @@ -300,17 +301,23 @@ export function deriveFileInspectorPaneLayout(input: { readonly viewportWidth: number; readonly preferredWidth?: number; readonly reservedLeadingWidth?: number; - readonly inspectorSizing?: "foldable" | "tablet"; - readonly foldableWindow?: FoldableWindow; + readonly inspectorSizing?: "android"; + readonly inspectorWindow?: InspectorWindow; }): FileInspectorPaneLayout { const viewportWidth = Math.max(0, input.viewportWidth); - if (input.inspectorSizing === "foldable") { - const window = input.foldableWindow; + if ( + input.inspectorSizing === "android" && + (input.inspectorWindow?.width ?? viewportWidth) < ANDROID_WIDE_INSPECTOR_MIN_WIDTH + ) { + const window = input.inspectorWindow; const gap = input.preferredWidth === undefined ? (window?.hinge?.width ?? 0) : 0; const defaultWidth = window ? window.width - (window.hinge?.x ?? window.width / 2) - gap : Math.max(0, viewportWidth - 4) / 2; - const supported = input.layout.usesSplitView && viewportWidth - gap >= 640; + const supported = + input.layout.usesSplitView && + defaultWidth >= 320 && + viewportWidth - gap - defaultWidth >= 280; return { supported, gap: supported ? gap : 0, @@ -318,13 +325,13 @@ export function deriveFileInspectorPaneLayout(input: { ? constrainAuxiliaryPaneWidth({ preferredWidth: input.preferredWidth ?? defaultWidth, availableWidth: viewportWidth - gap, - inspectorSizing: "foldable", - foldableWindow: window, + inspectorSizing: "android", + inspectorWindow: window, }) : null, }; } - if (input.inspectorSizing === "tablet") { + if (input.inspectorSizing === "android") { const supported = input.layout.usesSplitView && viewportWidth >= AUXILIARY_PANE_MIN_WIDTH + NATIVE_INSPECTOR_MIN_MAIN_WIDTH + 4; @@ -334,7 +341,8 @@ export function deriveFileInspectorPaneLayout(input: { ? constrainAuxiliaryPaneWidth({ preferredWidth: input.preferredWidth ?? viewportWidth * 0.4, availableWidth: viewportWidth, - inspectorSizing: "tablet", + inspectorSizing: "android", + inspectorWindow: input.inspectorWindow, }) : null, }; @@ -366,8 +374,8 @@ export function deriveFileInspectorPaneLayout(input: { export function constrainAuxiliaryPaneWidth(input: { readonly preferredWidth: number; readonly availableWidth: number; - readonly inspectorSizing?: "foldable" | "tablet"; - readonly foldableWindow?: FoldableWindow; + readonly inspectorSizing?: "android"; + readonly inspectorWindow?: InspectorWindow; }): number { const safePreferredWidth = Number.isFinite(input.preferredWidth) ? input.preferredWidth @@ -375,14 +383,17 @@ export function constrainAuxiliaryPaneWidth(input: { const availableWidth = Number.isFinite(input.availableWidth) ? Math.max(0, input.availableWidth) : 0; - if (input.inspectorSizing === "foldable") { - const window = input.foldableWindow; + if ( + input.inspectorSizing === "android" && + (input.inspectorWindow?.width ?? availableWidth) < ANDROID_WIDE_INSPECTOR_MIN_WIDTH + ) { + const window = input.inspectorWindow; const mainReserve = window ? Math.min(320, (window.hinge?.x ?? window.width / 2) - window.leadingWidth) : 324; return clamp(safePreferredWidth, 320, Math.max(320, availableWidth - mainReserve)); } - if (input.inspectorSizing === "tablet") { + if (input.inspectorSizing === "android") { return nativeInspectorColumnWidth({ viewportWidth: availableWidth, horizontalSafeArea: 4, diff --git a/apps/mobile/src/native/AndroidHingeObserver.android.tsx b/apps/mobile/src/native/AndroidHingeObserver.android.tsx index a82390413cec9..39a1594b536d2 100644 --- a/apps/mobile/src/native/AndroidHingeObserver.android.tsx +++ b/apps/mobile/src/native/AndroidHingeObserver.android.tsx @@ -4,9 +4,7 @@ import type { VerticalHinge } from "../lib/layout"; interface ObserverProps extends ViewProps { readonly onHingeChange: ( - event: NativeSyntheticEvent< - Partial - >, + event: NativeSyntheticEvent>, ) => void; } @@ -15,19 +13,15 @@ const Observer = requireNativeView("T3FoldPosture"); /** Reports a vertical hinge in this view's coordinates while the device is bent like a book. */ export function AndroidHingeObserver(props: { /** Receives the hinge in this view's coordinates and in window coordinates. */ - readonly onChange: ( - hinge: VerticalHinge | null, - windowHinge: VerticalHinge | null, - isFoldable: boolean, - ) => void; + readonly onChange: (hinge: VerticalHinge | null, windowHinge: VerticalHinge | null) => void; }) { return ( + onHingeChange={({ nativeEvent: { x, width, windowX } }) => x === undefined || width === undefined || windowX === undefined - ? props.onChange(null, null, isFoldable === true) - : props.onChange({ x, width }, { x: windowX, width }, isFoldable === true) + ? props.onChange(null, null) + : props.onChange({ x, width }, { x: windowX, width }) } style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }} /> diff --git a/apps/mobile/src/native/AndroidHingeObserver.tsx b/apps/mobile/src/native/AndroidHingeObserver.tsx index cddcf6c526b61..164ce6211a51f 100644 --- a/apps/mobile/src/native/AndroidHingeObserver.tsx +++ b/apps/mobile/src/native/AndroidHingeObserver.tsx @@ -1,11 +1,7 @@ import type { VerticalHinge } from "../lib/layout"; export function AndroidHingeObserver(_props: { - readonly onChange: ( - hinge: VerticalHinge | null, - windowHinge: VerticalHinge | null, - isFoldable: boolean, - ) => void; + readonly onChange: (hinge: VerticalHinge | null, windowHinge: VerticalHinge | null) => void; }) { return null; } From feedb7feaa949007293a5fe9b9b1d5ecb262940e Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 9 Nov 2026 23:02:50 -0800 Subject: [PATCH 09/13] fix(mobile): reserve the Android rail beside floating sidebars --- .../layout/AdaptiveWorkspaceLayout.tsx | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 873aecbc5a665..d70fd00352586 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -362,8 +362,7 @@ function AdaptiveWorkspaceLayoutContent( floatingTabs.row, floatingTabsEnabled, ]); - // A floating sidebar brings the rail with it. - const width = viewportWidth - (sidebarFloats ? 0 : navigationRailWidth); + const width = viewportWidth - navigationRailWidth; // The sidebar stays mounted under a workspace page, so its list is ready on the way back. const shouldRenderPrimarySidebar = layout.usesSplitView; const inspectorSizing = Platform.OS === "android" ? "android" : undefined; @@ -740,12 +739,11 @@ function AdaptiveWorkspaceLayoutContent( // against the edge that stays put while the other one slides, so nothing reflows after the // panes land. A sliding sidebar uncovers the strip it is about to fill, or covers the content // until it is gone. - const contentLeftTarget = sidebarFloats - ? 0 - : navigationRailWidth + - (shouldRenderPrimarySidebar && !workspacePage && panes.primarySidebarVisible - ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) - : 0); + const contentLeftTarget = + navigationRailWidth + + (!sidebarFloats && shouldRenderPrimarySidebar && !workspacePage && panes.primarySidebarVisible + ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) + : 0); const [contentAnchor, setContentAnchor] = useState({ viewportWidth, left: contentLeftTarget, @@ -878,7 +876,7 @@ function AdaptiveWorkspaceLayoutContent( Math.max( 0, viewportWidth - - (sidebarFloats ? 0 : renderedRailWidth.value) - + renderedRailWidth.value - (workspacePage || sidebarFloats ? 0 : renderedSidebarWidth.value) - renderedInspectorWidth.value - inspectorGap, @@ -1018,10 +1016,7 @@ function AdaptiveWorkspaceLayoutContent( > {shouldRenderNavigationRail ? ( - + ) : null} {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( Date: Mon, 9 Nov 2026 23:05:40 -0800 Subject: [PATCH 10/13] fix(mobile): retain inspectors when the saved width fits --- apps/mobile/src/lib/layout.test.ts | 15 +++++++++++++++ apps/mobile/src/lib/layout.ts | 23 ++++++++--------------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 8be7c4391aaec..852979b0e8f3f 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -344,6 +344,21 @@ describe("deriveWorkspacePaneLayout", () => { primarySidebarSuppressedByAuxiliary: false, auxiliaryPaneVisible: true, }); + expect( + deriveWorkspacePaneLayout({ + layout: deriveLayout({ width: 680, height: 900 }), + viewportWidth: 608, + inspectorSizing: "android", + inspectorWindow: { width: 680, leadingWidth: 72, hinge: null }, + primarySidebarPreferredVisible: false, + auxiliaryPanePreferredVisible: true, + auxiliaryPanePreferredWidth: 320, + }), + ).toMatchObject({ + primarySidebarVisible: false, + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: 320, + }); }); }); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index b15f225a1fcb7..368040c366384 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -314,22 +314,15 @@ export function deriveFileInspectorPaneLayout(input: { const defaultWidth = window ? window.width - (window.hinge?.x ?? window.width / 2) - gap : Math.max(0, viewportWidth - 4) / 2; + const width = constrainAuxiliaryPaneWidth({ + preferredWidth: input.preferredWidth ?? defaultWidth, + availableWidth: viewportWidth - gap, + inspectorSizing: "android", + inspectorWindow: window, + }); const supported = - input.layout.usesSplitView && - defaultWidth >= 320 && - viewportWidth - gap - defaultWidth >= 280; - return { - supported, - gap: supported ? gap : 0, - width: supported - ? constrainAuxiliaryPaneWidth({ - preferredWidth: input.preferredWidth ?? defaultWidth, - availableWidth: viewportWidth - gap, - inspectorSizing: "android", - inspectorWindow: window, - }) - : null, - }; + input.layout.usesSplitView && width >= 320 && viewportWidth - gap - width >= 280; + return { supported, gap: supported ? gap : 0, width: supported ? width : null }; } if (input.inspectorSizing === "android") { const supported = From ac94a2b9b2532954a116c52defce72b5321e5095 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 08:34:40 +0000 Subject: [PATCH 11/13] fix(mobile): reveal sidebar over android inspector --- .../src/features/layout/AdaptiveWorkspaceLayout.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index d70fd00352586..88ef3dfb8ce65 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -494,15 +494,22 @@ function AdaptiveWorkspaceLayoutContent( if (primarySidebarPinned) { return; } - if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { + if ( + !panes.primarySidebarVisible && + (panes.primarySidebarSuppressedByAuxiliary || + (Platform.OS === "android" && panes.auxiliaryPaneVisible)) + ) { setFileInspectorPreferredVisible(false); - setPrimarySidebarPreferredVisible(true); + setPrimarySidebarVisible(true); return; } setPrimarySidebarVisible(!preferredSidebarVisible); }; const revealPrimarySidebar = () => { - if (panes.primarySidebarSuppressedByAuxiliary) { + if ( + panes.primarySidebarSuppressedByAuxiliary || + (Platform.OS === "android" && panes.auxiliaryPaneVisible) + ) { setFileInspectorPreferredVisible(false); } setPrimarySidebarVisible(true); From a7c3eca520cd238cbbbef345ec579c92cdf1f0a0 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Tue, 10 Nov 2026 00:55:11 -0800 Subject: [PATCH 12/13] fix(mobile): keep inspectors behind floating sidebars --- .../layout/AdaptiveWorkspaceLayout.tsx | 15 +++++++----- .../layout/workspace-inspector-pane.tsx | 10 ++++---- apps/mobile/src/lib/layout.test.ts | 8 +++---- apps/mobile/src/lib/layout.ts | 23 +++++++++---------- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 88ef3dfb8ce65..0ba51298bdf63 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -470,6 +470,7 @@ function AdaptiveWorkspaceLayoutContent( ), ); setFileInspectorPreferredVisible(true); + if (Platform.OS === "android" && sidebarFloats) setPrimarySidebarVisible(false); } return () => { @@ -497,7 +498,7 @@ function AdaptiveWorkspaceLayoutContent( if ( !panes.primarySidebarVisible && (panes.primarySidebarSuppressedByAuxiliary || - (Platform.OS === "android" && panes.auxiliaryPaneVisible)) + (Platform.OS === "android" && !sidebarFloats && panes.auxiliaryPaneVisible)) ) { setFileInspectorPreferredVisible(false); setPrimarySidebarVisible(true); @@ -508,7 +509,7 @@ function AdaptiveWorkspaceLayoutContent( const revealPrimarySidebar = () => { if ( panes.primarySidebarSuppressedByAuxiliary || - (Platform.OS === "android" && panes.auxiliaryPaneVisible) + (Platform.OS === "android" && !sidebarFloats && panes.auxiliaryPaneVisible) ) { setFileInspectorPreferredVisible(false); } @@ -527,6 +528,7 @@ function AdaptiveWorkspaceLayoutContent( }; const showInspector = () => { setFileInspectorPreferredVisible(true); + if (Platform.OS === "android" && sidebarFloats) setPrimarySidebarVisible(false); if (fileInspector.supported) { setInspectorSheetRoute(null); return; @@ -549,8 +551,8 @@ function AdaptiveWorkspaceLayoutContent( }; useHardwareKeyboardCommand("files", handleOpenFilesCommand); const toggleAuxiliaryPane = () => { - if (fileInspector.supported) { - setFileInspectorPreferredVisible((current) => !current); + if (fileInspector.supported && fileInspectorPreferredVisible) { + setFileInspectorPreferredVisible(false); setInspectorSheetRoute(null); } else if (inspectorSheetOpen) dismissInspectorSheet(); else showInspector(); @@ -838,8 +840,8 @@ function AdaptiveWorkspaceLayoutContent( if (state && workspaceRoutesOf(state.routes.slice(0, state.index + 1)).overlays.length > 0) { return false; } - if (inspectorOpen) setFileInspectorPreferredVisible(false); - else setPrimarySidebarVisible(false); + if (floatingSidebarOpen) setPrimarySidebarVisible(false); + else setFileInspectorPreferredVisible(false); return true; }); return () => subscription.remove(); @@ -1163,6 +1165,7 @@ function AdaptiveWorkspaceLayoutContent( ; readonly active?: boolean; + readonly obscured?: boolean; readonly onClose: () => void; readonly onClosed?: () => void; readonly panes: WorkspacePaneLayout; @@ -33,6 +34,7 @@ export function WorkspaceInspectorPane(props: { const inspectorSupported = props.content !== undefined && inspectorWidth !== null; const inspectorVisible = inspectorSupported && panes.auxiliaryPaneVisible && (props.active ?? true); + const inspectorInteractive = inspectorVisible && !props.obscured; const resizeStartWidth = useRef(0); const [resizing, setResizing] = useState(false); @@ -92,7 +94,7 @@ export function WorkspaceInspectorPane(props: { return ( <> - {inspectorVisible ? ( + {inspectorInteractive ? ( diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 852979b0e8f3f..28dd2a8cb91af 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -211,7 +211,7 @@ describe("deriveWorkspacePaneLayout", () => { auxiliaryPaneVisible: true, auxiliaryPaneWidth: expect.closeTo(inspectorWidth), auxiliaryPaneGap: gap, - primarySidebarVisible: false, + primarySidebarVisible: input.layout.variant === "medium", }); expect(width - inspectorWidth - gap).toBeCloseTo(mainEnd); expect( @@ -248,8 +248,8 @@ describe("deriveWorkspacePaneLayout", () => { auxiliaryPanePreferredVisible: true, }; expect(deriveWorkspacePaneLayout(input)).toMatchObject({ - primarySidebarVisible: false, - primarySidebarSuppressedByAuxiliary: true, + primarySidebarVisible: primarySidebarFloats, + primarySidebarSuppressedByAuxiliary: !primarySidebarFloats, auxiliaryPaneVisible: true, auxiliaryPaneWidth: defaultWidth, contentPaneWidth: viewportWidth, @@ -265,7 +265,7 @@ describe("deriveWorkspacePaneLayout", () => { deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredWidth: width }), ).toMatchObject({ auxiliaryPaneWidth: width, - primarySidebarVisible: !primarySidebarFloats && viewportWidth === 1100 && width <= 400, + primarySidebarVisible: primarySidebarFloats || (viewportWidth === 1100 && width <= 400), }); } expect( diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 368040c366384..118d8c7c38a0d 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -251,18 +251,17 @@ export function deriveWorkspacePaneLayout(input: { const primarySidebarSuppressedByAuxiliary = preferredPrimarySidebarVisible && auxiliaryPaneVisible && - ((input.inspectorSizing === "android" && floats) || - (!floats && - inspectorWidth !== null && - input.layout.listPaneWidth !== null && - viewportWidth - - (input.nativeWorkspace ? (input.horizontalSafeArea ?? 0) : 0) - - input.layout.listPaneWidth - - (input.layout.listPaneGap ?? 0) - - inspectorWidth < - (input.nativeWorkspace || input.inspectorSizing === "android" - ? NATIVE_INSPECTOR_MIN_MAIN_WIDTH - : FILE_INSPECTOR_MIN_MAIN_WIDTH))); + !floats && + inspectorWidth !== null && + input.layout.listPaneWidth !== null && + viewportWidth - + (input.nativeWorkspace ? (input.horizontalSafeArea ?? 0) : 0) - + input.layout.listPaneWidth - + (input.layout.listPaneGap ?? 0) - + inspectorWidth < + (input.nativeWorkspace || input.inspectorSizing === "android" + ? NATIVE_INSPECTOR_MIN_MAIN_WIDTH + : FILE_INSPECTOR_MIN_MAIN_WIDTH); const primarySidebarVisible = preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; const primarySidebarWidth = primarySidebarVisible ? preferredPrimarySidebarWidth : 0; From a9c2e724e9d26e797da0b67de0923485fdcb6970 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Tue, 10 Nov 2026 01:02:04 -0800 Subject: [PATCH 13/13] fix(mobile): keep pane boundaries aligned to physical hinges --- .../layout/workspace-inspector-pane.tsx | 2 +- apps/mobile/src/lib/layout.test.ts | 33 +++++++++++++++---- apps/mobile/src/lib/layout.ts | 9 ++++- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index fa63e1be55d8a..28f1ec8f5caee 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -94,7 +94,7 @@ export function WorkspaceInspectorPane(props: { return ( <> - {inspectorInteractive ? ( + {inspectorInteractive && (panes.auxiliaryPaneGap ?? 0) === 0 ? ( { leadingWidth: 72, hinge: { x: 372, width: 8 }, inspectorWidth: 364, - maximumWidth: 372, + maximumWidth: 364, mainEnd: 372, gap: 8, }, + { + width: 1200, + leadingWidth: 72, + hinge: { x: 600, width: 24 }, + inspectorWidth: 576, + maximumWidth: 576, + mainEnd: 600, + gap: 24, + }, { width: 1000, leadingWidth: 72, @@ -218,14 +227,24 @@ describe("deriveWorkspacePaneLayout", () => { deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredWidth: width }), ).toMatchObject({ auxiliaryPaneWidth: expect.closeTo(maximumWidth), - auxiliaryPaneGap: 0, + auxiliaryPaneGap: gap, }); - expect( - deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredWidth: 320 }), - ).toMatchObject({ - auxiliaryPaneWidth: 320, - auxiliaryPaneGap: 0, + const resized = deriveWorkspacePaneLayout({ ...input, auxiliaryPanePreferredWidth: 320 }); + expect(resized).toMatchObject({ + auxiliaryPaneWidth: gap > 0 ? inspectorWidth : 320, + auxiliaryPaneGap: gap, }); + if (gap > 0) { + const restored = deriveWorkspacePaneLayout({ + ...input, + auxiliaryPanePreferredWidth: resized.auxiliaryPaneWidth ?? undefined, + }); + expect(restored.auxiliaryPaneWidth).toBe(inspectorWidth); + expect(restored.auxiliaryPaneGap).toBe(gap); + expect(width - (restored.auxiliaryPaneWidth ?? 0) - (restored.auxiliaryPaneGap ?? 0)).toBe( + mainEnd, + ); + } }, ); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 118d8c7c38a0d..738d66f39f30f 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -304,11 +304,18 @@ export function deriveFileInspectorPaneLayout(input: { readonly inspectorWindow?: InspectorWindow; }): FileInspectorPaneLayout { const viewportWidth = Math.max(0, input.viewportWidth); + const window = input.inspectorWindow; + const hinge = window?.hinge; + if (input.inspectorSizing === "android" && window && hinge && hinge.width > 0) { + const width = window.width - hinge.x - hinge.width; + const supported = + input.layout.usesSplitView && width >= 320 && hinge.x - window.leadingWidth >= 280; + return { supported, gap: supported ? hinge.width : 0, width: supported ? width : null }; + } if ( input.inspectorSizing === "android" && (input.inspectorWindow?.width ?? viewportWidth) < ANDROID_WIDE_INSPECTOR_MIN_WIDTH ) { - const window = input.inspectorWindow; const gap = input.preferredWidth === undefined ? (window?.hinge?.width ?? 0) : 0; const defaultWidth = window ? window.width - (window.hinge?.x ?? window.width / 2) - gap