From 77a2e69107159c77e44c507c9ed046bbfaa3b8ed Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 17 Oct 2026 19:36:45 -0700 Subject: [PATCH] fix(android): foldable sidebar and layout fixes Combines #21049, #21044, #21043, and #21041, and adds: - The sidebar opens threads before the first navigation action. - The Nightly badge hides when the narrow sidebar header cannot fit it. - The 640dp Android reading width from #19424. Co-Authored-By: Claude Opus 5.5 --- .../T3WorkspaceNavigationModule.kt | 83 ++++++------------- apps/mobile/src/Stack.tsx | 9 +- .../src/components/CompactBrandTitle.tsx | 65 +++++++++++---- .../components/ui/NativeSwitch.android.tsx | 23 +++-- apps/mobile/src/features/home/HomeScreen.tsx | 22 ++++- .../src/features/home/ThreadListToolbar.tsx | 11 ++- .../layout/AdaptiveWorkspaceLayout.tsx | 60 +++----------- .../PullRequestToolbar.android.tsx | 17 +++- .../features/threads/ThreadDetailScreen.tsx | 13 ++- .../features/threads/thread-list-v2-items.tsx | 3 +- .../AndroidWorkspaceNavigation.android.tsx | 39 ++------- .../workspace/AndroidWorkspaceNavigation.tsx | 7 +- .../AndroidWorkspaceSidebar.android.tsx | 2 +- ...ndroid-workspace-navigation-colors.test.ts | 33 +++----- .../android-workspace-navigation-colors.ts | 9 +- .../useAndroidWorkspaceNavigation.ts | 11 +-- .../workspace/useAndroidWorkspaceSelection.ts | 8 +- .../lib/android-workspace-navigation.test.ts | 45 ++++++++++ .../src/lib/android-workspace-navigation.ts | 45 +++++++++- apps/mobile/src/lib/layout.test.ts | 25 ++---- apps/mobile/src/lib/layout.ts | 17 ++-- apps/mobile/src/native/useOpenPullRequest.ts | 9 +- 22 files changed, 297 insertions(+), 259 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index e13c1fbbae50..3d48060dcaf5 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -3,16 +3,12 @@ package expo.modules.t3nativecontrols import android.content.Context import android.view.ViewGroup import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem -import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -39,7 +35,6 @@ class T3WorkspaceNavigationModule : Module() { Prop("selectedTab") { view: T3WorkspaceNavigationView, value: String -> view.selectedTab = value } - Prop("rail") { view: T3WorkspaceNavigationView, value: Boolean -> view.rail = value } Prop("dark") { view: T3WorkspaceNavigationView, value: Boolean -> view.dark = value } Prop("textScale") { view: T3WorkspaceNavigationView, value: Float -> view.textScale = value } Prop("containerColor") { view: T3WorkspaceNavigationView, value: Int -> @@ -59,7 +54,7 @@ class T3WorkspaceNavigationModule : Module() { } } -/** One Material3 selection surface moves from below the list to its leading edge. */ +/** Material3 bottom navigation for the compact workspace. */ class T3WorkspaceNavigationView( context: Context, appContext: AppContext @@ -69,7 +64,6 @@ class T3WorkspaceNavigationView( private val onTabSelected by EventDispatcher() var selectedTab by mutableStateOf("threads") - var rail by mutableStateOf(false) var dark by mutableStateOf(false) var textScale by mutableStateOf(1f) var containerColor by mutableStateOf(0) @@ -108,58 +102,29 @@ class T3WorkspaceNavigationView( setContent { MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) { val tabs = listOf("threads", "pullRequests") - if (rail) { - NavigationRail( - modifier = Modifier.fillMaxSize(), - containerColor = Color(containerColor), - windowInsets = WindowInsets(0), - ) { - Spacer(Modifier.weight(1f)) - tabs.forEach { tab -> - NavigationRailItem( - selected = selectedTab == tab, - onClick = { onTabSelected(mapOf("tab" to tab)) }, - icon = { tabIcon(tab) }, - label = { - val title = if (tab == "threads") "Chats" else "PRs" - Text(title, fontSize = (12 * textScale).sp) - }, - colors = NavigationRailItemDefaults.colors( - selectedIconColor = Color(selectedColor), - selectedTextColor = Color(selectedColor), - indicatorColor = Color(indicatorColor), - unselectedIconColor = Color(unselectedColor), - unselectedTextColor = Color(unselectedColor), - ), - ) - } - Spacer(Modifier.weight(1f)) - } - } else { - NavigationBar( - modifier = Modifier.fillMaxSize(), - containerColor = Color(containerColor), - tonalElevation = androidx.compose.ui.unit.Dp(0f), - windowInsets = WindowInsets(0), - ) { - tabs.forEach { tab -> - NavigationBarItem( - selected = selectedTab == tab, - onClick = { onTabSelected(mapOf("tab" to tab)) }, - icon = { tabIcon(tab) }, - label = { - val title = if (tab == "threads") "Chats" else "Pull requests" - Text(title, fontSize = (12 * textScale).sp) - }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = Color(selectedColor), - selectedTextColor = Color(selectedColor), - indicatorColor = Color(indicatorColor), - unselectedIconColor = Color(unselectedColor), - unselectedTextColor = Color(unselectedColor), - ), - ) - } + NavigationBar( + modifier = Modifier.fillMaxSize(), + containerColor = Color(containerColor), + tonalElevation = androidx.compose.ui.unit.Dp(0f), + windowInsets = WindowInsets(0), + ) { + tabs.forEach { tab -> + NavigationBarItem( + selected = selectedTab == tab, + onClick = { onTabSelected(mapOf("tab" to tab)) }, + icon = { tabIcon(tab) }, + label = { + val title = if (tab == "threads") "Chats" else "Pull requests" + Text(title, fontSize = (12 * textScale).sp) + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = Color(selectedColor), + selectedTextColor = Color(selectedColor), + indicatorColor = Color(indicatorColor), + unselectedIconColor = Color(unselectedColor), + unselectedTextColor = Color(unselectedColor), + ), + ) } } } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index a8c13378d99c..1d5be66de438 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -30,6 +30,7 @@ import { import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/ui/AppText"; +import { androidWorkspaceDestinationAnimation } from "./lib/android-workspace-navigation"; import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen"; import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider"; @@ -1007,15 +1008,17 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) { const { width, height } = useWindowDimensions(); const nativeMetrics = useNativeLayoutMetrics(); - const usesWorkspaceFlowScreens = - Platform.OS === "android" || deriveLayout({ width, height, nativeMetrics }).usesSplitView; + const usesSplitView = deriveLayout({ width, height, nativeMetrics }).usesSplitView; + const usesWorkspaceFlowScreens = Platform.OS === "android" || usesSplitView; return ( { if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") { - return {}; + return Platform.OS === "android" + ? { animation: androidWorkspaceDestinationAnimation(route.name, usesSplitView) } + : {}; } return usesWorkspaceFlowScreens diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 689e115183b1..ec99a35e458f 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -1,6 +1,7 @@ import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns"; import Constants from "expo-constants"; import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; +import { useState } from "react"; import { Platform, View } from "react-native"; import { AppText as Text } from "./ui/AppText"; @@ -18,20 +19,37 @@ export function CompactBrandTitle( props: { readonly allowFontScaling?: boolean; readonly accessibilityLabel?: string; + /** + * Fills the parent row and drops the stage badge when the full title does not fit. + * Narrow Android sidebar headers otherwise draw their actions over the badge. + */ + readonly fit?: boolean; } = {}, ) { const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); const titleOffset = brandTitleOffset(); const { scale } = useAndroidControlSizing(); + const [availableWidth, setAvailableWidth] = useState(null); + // Measured only while the badge shows, so hiding it does not make it look like it fits. + const [fullWidth, setFullWidth] = useState(null); + const showStage = + !props.fit || availableWidth === null || fullWidth === null || fullWidth <= availableWidth; - return ( + const title = ( setFullWidth(event.nativeEvent.layout.width) : undefined + } aria-level={1} accessibilityLabel={props.accessibilityLabel ?? "T3 Code, Threads"} accessible role="heading" className="flex-row items-center gap-1.5" - style={[{ marginLeft: titleOffset }, Platform.OS === "android" && { gap: 5.25 * scale }]} + style={[ + { marginLeft: titleOffset }, + Platform.OS === "android" && { gap: 5.25 * scale }, + props.fit && { flexShrink: 0 }, + ]} > Code - - - {stageLabel} - - + + {stageLabel} + + + ) : null} + + ); + + return props.fit ? ( + setAvailableWidth(event.nativeEvent.layout.width)} + > + {title} + ) : ( + title ); } diff --git a/apps/mobile/src/components/ui/NativeSwitch.android.tsx b/apps/mobile/src/components/ui/NativeSwitch.android.tsx index fa0ffbeff38d..a6adbb706f65 100644 --- a/apps/mobile/src/components/ui/NativeSwitch.android.tsx +++ b/apps/mobile/src/components/ui/NativeSwitch.android.tsx @@ -1,35 +1,34 @@ import { NativeThemeHost } from "./NativeThemeHost.android"; import { Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; -import { View } from "react-native"; +import { Pressable, View } from "react-native"; import type { ThemedSwitchProps } from "./NativeSwitch.types"; import { useControlTheme } from "./useControlTheme"; -/** Material's native switch, with the same palette and accessibility contract as our RN controls. */ +/** + * Material's native switch, with the same palette and accessibility contract as our RN controls. + * Compose only draws it. The RN Pressable owns the touch so it wins over a pressable parent row; + * with Compose handling touches, a tap on the switch ran the row's onPress instead. + */ export function NativeSwitch(props: ThemedSwitchProps) { const { colors } = useControlTheme(); - const toggle = () => { - if (!props.disabled) props.onValueChange?.(!props.value); - }; return ( - props.onValueChange?.(!props.value)} accessibilityLabel={props.accessibilityLabel} accessibilityHint={props.accessibilityHint} accessibilityRole="switch" accessibilityState={{ checked: Boolean(props.value), disabled: Boolean(props.disabled) }} - accessibilityActions={[{ name: "activate", label: "Toggle" }]} - onAccessibilityAction={toggle} style={props.style} testID={props.testID} > - + - + ); } diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e2af1860d7bd..a6f098a05ea4 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -18,6 +18,7 @@ import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentP import { Platform, ScrollView, + useWindowDimensions, View, type ScrollViewProps, type GestureResponderEvent, @@ -30,6 +31,10 @@ import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSw import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "../../lib/cn"; +import { + ANDROID_READING_MAX_WIDTH, + deriveCenteredContentHorizontalPadding, +} from "../../lib/layout"; import { EmptyState } from "../../components/ui/EmptyState"; import { FloatingActionButton } from "../../components/ui/FloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; @@ -264,6 +269,17 @@ export function HomeScreen(props: HomeScreenProps) { const contentSideInsets = usesNativeWorkspaceColumns ? (columnMetrics ?? screenMetrics)?.safeArea : undefined; + // Android lists that span a wide window (foldables in portrait) keep the + // reading width, so rows stay clear of the edges and the camera cutout. + const { width: windowWidth } = useWindowDimensions(); + const androidListGutter = + Platform.OS === "android" + ? deriveCenteredContentHorizontalPadding({ + viewportWidth: windowWidth, + maxContentWidth: ANDROID_READING_MAX_WIDTH, + minimumPadding: 0, + }) + : 0; const iosBottomClearance = Math.max( columnMetrics?.safeArea.bottom ?? insets.bottom, 24, @@ -1027,8 +1043,10 @@ export function HomeScreen(props: HomeScreenProps) { {...scrollGateHandlers} scrollEventThrottle={16} contentContainerStyle={{ - paddingLeft: (contentSideInsets?.left ?? 0) + (primaryColumn ? 8 : 0), - paddingRight: (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0), + paddingLeft: + (contentSideInsets?.left ?? 0) + (primaryColumn ? 8 : 0) + androidListGutter, + paddingRight: + (contentSideInsets?.right ?? 0) + (primaryColumn ? 8 : 0) + androidListGutter, paddingBottom: Platform.OS === "ios" ? iosBottomClearance diff --git a/apps/mobile/src/features/home/ThreadListToolbar.tsx b/apps/mobile/src/features/home/ThreadListToolbar.tsx index 298ab2903df3..873b5f1b4ebd 100644 --- a/apps/mobile/src/features/home/ThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/ThreadListToolbar.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; import { useNavigation, useNavigationState } from "@react-navigation/native"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +import { useWorkspaceListNavigation } from "../workspace/useAndroidWorkspaceNavigation"; import { BackHandler, Keyboard, @@ -47,6 +48,7 @@ export function ThreadListToolbar(props: { const { height: toolbarHeight, ...headerPadding } = useToolbarLayout(); const { state } = useWorkspaceState(); const { onRequestVisibility, onSearchQueryChange } = props; + const showPullRequests = useWorkspaceListNavigation("pullRequests"); const searchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const searching = searchOpen || props.searchQuery.length > 0; @@ -121,9 +123,16 @@ export function ThreadListToolbar(props: { } + brand={} /> + {props.sidebar && workspace ? ( + + ) : null} (null); @@ -267,11 +261,6 @@ function AdaptiveWorkspaceLayoutContent( usesSplitView: baseLayout.usesSplitView, }); const showPrimarySidebar = primarySidebarPinned || primarySidebarPreferredVisible; - const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView; - const navigationRailWidth = - shouldRenderNavigationRail && showPrimarySidebar - ? ANDROID_WORKSPACE_RAIL_WIDTH * Math.max(1, androidControlScale) - : 0; const [hinge, setHinge] = useState(null); const [windowHinge, setWindowHinge] = useState(null); const handleHingeChange = useCallback( @@ -282,29 +271,28 @@ function AdaptiveWorkspaceLayoutContent( [], ); const layout = useMemo( - () => - alignListPaneToHinge({ - layout: baseLayout, - hinge, - viewportWidth, - leadingWidth: navigationRailWidth, - }), - [baseLayout, hinge, viewportWidth, navigationRailWidth], + () => alignListPaneToHinge({ layout: baseLayout, hinge, viewportWidth }), + [baseLayout, hinge, viewportWidth], ); - const width = viewportWidth - navigationRailWidth; const shouldRenderPrimarySidebar = layout.usesSplitView; const fileInspector = useMemo( () => deriveFileInspectorPaneLayout({ layout, - viewportWidth: width, + viewportWidth, preferredWidth: fileInspectorPreferredWidth ?? undefined, reservedLeadingWidth: shouldRenderPrimarySidebar && showPrimarySidebar ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, }), - [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width], + [ + fileInspectorPreferredWidth, + layout, + showPrimarySidebar, + shouldRenderPrimarySidebar, + viewportWidth, + ], ); const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole = focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary"); @@ -320,7 +308,7 @@ function AdaptiveWorkspaceLayoutContent( () => deriveWorkspacePaneLayout({ layout, - viewportWidth: width, + viewportWidth, primarySidebarPreferredVisible: showPrimarySidebar, auxiliaryPanePreferredVisible, auxiliaryPaneRole, @@ -332,7 +320,7 @@ function AdaptiveWorkspaceLayoutContent( auxiliaryPanePreferredWidth, layout, showPrimarySidebar, - width, + viewportWidth, ], ); const activeThread = parseActiveThreadPath(pathname); @@ -582,7 +570,6 @@ function AdaptiveWorkspaceLayoutContent( const renderedSidebarWidth = useSharedValue( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, ); - const renderedRailWidth = useSharedValue(navigationRailWidth); useEffect(() => { if (nativeWorkspace) return; const targetWidth = panes.primarySidebarVisible @@ -591,19 +578,15 @@ function AdaptiveWorkspaceLayoutContent( renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING, (finished) => { if (finished && Platform.OS === "android") runOnJS(settleContentWidth)(contentRevision); }); - renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING)); }, [ nativeWorkspace, - navigationRailWidth, layout.listPaneWidth, layout.listPaneGap, panes.primarySidebarVisible, renderedSidebarWidth, - renderedRailWidth, settleContentWidth, contentRevision, ]); - const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value })); // 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; @@ -624,13 +607,7 @@ function AdaptiveWorkspaceLayoutContent( // merely clip/reveal it over a matching background. const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); const renderedContentWidth = useDerivedValue(() => - Math.max( - 0, - viewportWidth - - renderedRailWidth.value - - renderedSidebarWidth.value - - renderedInspectorWidth.value, - ), + Math.max(0, viewportWidth - renderedSidebarWidth.value - renderedInspectorWidth.value), ); const selectAndroidWorkspace = useAndroidWorkspaceSelection(); @@ -745,17 +722,6 @@ function AdaptiveWorkspaceLayoutContent( className={Platform.OS === "android" ? "flex-1 flex-row bg-header" : "flex-1 flex-row"} > - {shouldRenderNavigationRail ? ( - 0 ? "auto" : "no-hide-descendants"} - pointerEvents={navigationRailWidth > 0 ? "auto" : "none"} - style={railAnimatedStyle} - > - - - ) : null} {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( void }) { +export function PullRequestToolbar(props: { + readonly sidebar?: boolean; + readonly onRequestVisibility?: () => void; +}) { const navigation = useNavigation(); const workspace = useWorkspaceTabs()!; const { dispatch, getScrollOffset, recordScrollOffset, isTabActive } = workspace; const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home"); const listFocused = homeFocused && workspace.state.selectedTab === "pullRequests"; const { onRequestVisibility } = props; + const showThreads = useWorkspaceListNavigation("threads"); const tab = workspace.state.tabs.pullRequests; const input = useRef(null); const searching = tab.searchMode !== "resting"; @@ -73,6 +78,7 @@ export function PullRequestToolbar(props: { readonly onRequestVisibility?: () => } onPress={() => @@ -98,6 +104,15 @@ export function PullRequestToolbar(props: { readonly onRequestVisibility?: () => searching ? [] : [ + ...(props.sidebar + ? [ + { + accessibilityLabel: "Show threads", + icon: "text.bubble", + onPress: showThreads, + } as const, + ] + : []), { accessibilityLabel: "Search pull requests", icon: "magnifyingglass", diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 48f69837d670..6bc5084e0d53 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -97,7 +97,11 @@ import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; -import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; +import { + ANDROID_READING_MAX_WIDTH, + CHAT_CONTENT_MAX_WIDTH, + type LayoutVariant, +} from "../../lib/layout"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; import { deviceEnvironment } from "../../state/device"; import { useEnvironmentQuery } from "../../state/query"; @@ -768,7 +772,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; - const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; + const contentMaxWidth = + Platform.OS === "android" + ? ANDROID_READING_MAX_WIDTH + : isSplitLayout + ? CHAT_CONTENT_MAX_WIDTH + : undefined; const providerSubagentProvider = props.serverConfig?.providers.find( (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId, ); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index b08237b70e0e..bced3d955189 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1198,7 +1198,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); - if (!canOperateThread) return rowContent(() => {}); + if (!canOperateThread) + return {rowContent(() => {})}; return ( diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx index addf9718b09d..552b54c4fcfc 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx @@ -1,6 +1,6 @@ import { requireNativeView } from "expo"; -import { CommonActions, useNavigation } from "@react-navigation/native"; -import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation"; +import { useNavigation } from "@react-navigation/native"; +import { resetAndroidWorkspace } from "../../lib/android-workspace-navigation"; import { Keyboard, processColor, @@ -17,10 +17,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import type { WorkspaceTab } from "./workspace-tabs-state"; -export const ANDROID_WORKSPACE_RAIL_WIDTH = 80; - interface NativeNavigationProps extends ViewProps { - readonly rail: boolean; readonly selectedTab: WorkspaceTab; readonly dark: boolean; readonly textScale: number; @@ -38,37 +35,20 @@ function nativeColor(value: string) { return typeof color === "number" ? color : 0; } -export function AndroidWorkspaceNavigation(props: { - readonly rail?: boolean; - readonly onSelect?: () => void; -}) { +export function AndroidWorkspaceNavigation() { const workspace = useWorkspaceTabs()!; const navigation = useNavigation(); const insets = useSafeAreaInsets(); const keyboardVisible = useKeyboardState((state) => state.isVisible); const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const { scale } = useAndroidControlSizing(); - const rail = Boolean(props.rail); const { containerColor, indicatorColor, selectedColor, unselectedColor } = - androidWorkspaceNavigationColors(colors, rail); - if (!rail && keyboardVisible) return null; + androidWorkspaceNavigationColors(colors); + if (keyboardVisible) return null; return ( - + - CommonActions.reset(androidWorkspaceNavigationState(state)), - ); + resetAndroidWorkspace(navigation); } - props.onSelect?.(); }} - style={rail ? { flex: 1 } : { height: 80 * Math.max(1, scale) }} + style={{ height: 80 * Math.max(1, scale) }} /> ); diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx index 86709fe31937..251dc977a9dd 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx @@ -1,8 +1,3 @@ -export function AndroidWorkspaceNavigation(_props: { - readonly rail?: boolean; - readonly onSelect?: () => void; -}) { +export function AndroidWorkspaceNavigation() { return null; } - -export const ANDROID_WORKSPACE_RAIL_WIDTH = 80; diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx index 0db7c38fd43d..f59d31037f6e 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx @@ -17,7 +17,7 @@ export function AndroidWorkspaceSidebar(props: { ) : ( - + diff --git a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts index efdf234e6c9d..00418107c956 100644 --- a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts +++ b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts @@ -34,19 +34,14 @@ function expectVisibleSelection(colors: ReturnType { - it.each(MOBILE_THEME_IDS)( - "keeps %s selection visible in both navigation positions", - (themeId) => { - for (const appearance of ["light", "dark"] as const) { - const variables = getMobileThemeRuntimeVariables(themeId, appearance, "android"); - const snapshot = { ...variables }; - for (const rail of [false, true]) { - expectVisibleSelection(androidWorkspaceNavigationColors(variables, rail)); - } - expect(variables).toEqual(snapshot); - } - }, - ); + it.each(MOBILE_THEME_IDS)("keeps %s selection visible", (themeId) => { + for (const appearance of ["light", "dark"] as const) { + const variables = getMobileThemeRuntimeVariables(themeId, appearance, "android"); + const snapshot = { ...variables }; + expectVisibleSelection(androidWorkspaceNavigationColors(variables)); + expect(variables).toEqual(snapshot); + } + }); it.each(["#6750a4", "#006a6a", "#9c4146"])( "keeps Material You selection visible for system seed %s", @@ -90,19 +85,17 @@ describe("Android workspace navigation colors", () => { appearance, getMobileThemeRuntimeVariables("material-you", appearance, "android"), ); - for (const rail of [false, true]) { - expectVisibleSelection(androidWorkspaceNavigationColors(variables, rail)); - } + expectVisibleSelection(androidWorkspaceNavigationColors(variables)); } }, ); it("preserves an already visible tonal fill", () => { const variables = getMobileThemeRuntimeVariables("t3-chat", "light", "android"); - const colors = androidWorkspaceNavigationColors( - { ...variables, "--color-secondary": "#e091cf" }, - true, - ); + const colors = androidWorkspaceNavigationColors({ + ...variables, + "--color-secondary": "#e091cf", + }); expect(colors.indicatorColor).toBe("#e091cf"); expectVisibleSelection(colors); }); diff --git a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts index 774f20aad8bb..efcade30fbe2 100644 --- a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts +++ b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts @@ -5,11 +5,12 @@ import { type MobileThemeVariables, } from "../../lib/mobileTheme"; -export function androidWorkspaceNavigationColors(colors: MobileThemeVariables, rail: boolean) { +export function androidWorkspaceNavigationColors(colors: MobileThemeVariables) { const header = flattenThemeColor(colors["--color-header"], colors["--color-screen"]); - const containerColor = rail - ? header - : flattenThemeColor(themeColorWithAlpha(colors["--color-header-foreground"], 0.05), header); + const containerColor = flattenThemeColor( + themeColorWithAlpha(colors["--color-header-foreground"], 0.05), + header, + ); const indicatorColor = themeColorWithContrast( flattenThemeColor(colors["--color-secondary"], containerColor), containerColor, diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts b/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts index 803b571b135a..a2f30627b8d9 100644 --- a/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts +++ b/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts @@ -1,12 +1,7 @@ -import { - CommonActions, - StackActions, - useFocusEffect, - useNavigation, -} from "@react-navigation/native"; +import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import { useCallback } from "react"; import { BackHandler, Keyboard, Platform } from "react-native"; -import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation"; +import { resetAndroidWorkspace } from "../../lib/android-workspace-navigation"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import type { WorkspaceTab } from "./workspace-tabs-state"; @@ -16,7 +11,7 @@ export function useWorkspaceListNavigation(tab: WorkspaceTab) { return useCallback(() => { if (Platform.OS === "android") { dispatch?.({ type: "select", tab }); - navigation.dispatch((state) => CommonActions.reset(androidWorkspaceNavigationState(state))); + resetAndroidWorkspace(navigation); } else if (navigation.canGoBack()) navigation.goBack(); else navigation.dispatch(StackActions.replace("Home")); }, [navigation, dispatch, tab]); diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts index 46f49e9c071e..fc536eedf3f0 100644 --- a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts +++ b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts @@ -1,8 +1,8 @@ -import { CommonActions, useNavigation } from "@react-navigation/native"; +import { useNavigation } from "@react-navigation/native"; import { useCallback } from "react"; import { Keyboard } from "react-native"; import { - androidWorkspaceNavigationState, + resetAndroidWorkspace, type AndroidWorkspaceDestination, } from "../../lib/android-workspace-navigation"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; @@ -20,9 +20,7 @@ export function useAndroidWorkspaceSelection() { type: "select", tab: destination.name === "Thread" ? "threads" : "pullRequests", }); - navigation.dispatch((state) => - CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)), - ); + resetAndroidWorkspace(navigation, destination, reviewers); }, [navigation, dispatch], ); diff --git a/apps/mobile/src/lib/android-workspace-navigation.test.ts b/apps/mobile/src/lib/android-workspace-navigation.test.ts index a9bdef84e607..68f3732acabe 100644 --- a/apps/mobile/src/lib/android-workspace-navigation.test.ts +++ b/apps/mobile/src/lib/android-workspace-navigation.test.ts @@ -2,7 +2,9 @@ import * as NodeModule from "node:module"; import { describe, expect, it } from "vite-plus/test"; import { androidWorkspaceNavigationState, + resetAndroidWorkspace, type AndroidWorkspaceDestination, + type AndroidWorkspaceResetAction, } from "./android-workspace-navigation"; const require = NodeModule.createRequire(import.meta.url); @@ -183,4 +185,47 @@ describe("Android workspace navigation", () => { expect(state.routes[0]?.key).toBe(previous.routes[0]?.key); expect(state.index).toBe(0); }); + + it("resets the root stack when a nested Settings stack has focus", () => { + const root = apply(router.getInitialState(options), StackActions.push("SettingsSheet")); + const settings = router.getInitialState({ + ...options, + routeNames: ["SettingsContent", "SettingsEnvironments"], + }); + const actions: AndroidWorkspaceResetAction[] = []; + const rootNavigation = { + dispatch: () => undefined, + getParent: () => undefined, + getState: () => root, + }; + resetAndroidWorkspace( + { + dispatch: (action) => actions.push(action), + getParent: () => rootNavigation, + getState: () => settings, + }, + thread, + ); + const [action] = actions; + expect(action?.target).toBe(root.key); + expect(names(apply(root, action!))).toEqual(["Home", "Thread"]); + }); + + it("resets from the navigation container before any navigation action", () => { + const root = router.getInitialState(options); + const actions: AndroidWorkspaceResetAction[] = []; + // The container only learns the root state after the first action. + resetAndroidWorkspace( + { + dispatch: (action) => actions.push(action), + getParent: () => undefined, + getState: () => undefined, + getRootState: () => root, + }, + thread, + ); + const [action] = actions; + expect(action?.target).toBe(root.key); + expect(names(apply(root, action!))).toEqual(["Home", "Thread"]); + }); }); diff --git a/apps/mobile/src/lib/android-workspace-navigation.ts b/apps/mobile/src/lib/android-workspace-navigation.ts index 2a26e5aceef0..1f604d9182e5 100644 --- a/apps/mobile/src/lib/android-workspace-navigation.ts +++ b/apps/mobile/src/lib/android-workspace-navigation.ts @@ -1,9 +1,21 @@ -import type { NavigationState } from "@react-navigation/native"; +import type { CommonActions, NavigationState } from "@react-navigation/native"; export type AndroidWorkspaceDestination = | { readonly name: "Thread"; readonly params: ReactNavigation.RootParamList["Thread"] } | { readonly name: "PullRequest"; readonly params: ReactNavigation.RootParamList["PullRequest"] }; +export type AndroidWorkspaceResetAction = ReturnType & { + readonly target: string; +}; + +interface WorkspaceNavigation { + readonly dispatch: (action: AndroidWorkspaceResetAction) => void; + readonly getParent: () => WorkspaceNavigation | undefined; + readonly getState: () => NavigationState | undefined; + /** Only on the navigation container, whose `getState` stays empty until the first action. */ + readonly getRootState?: () => NavigationState | undefined; +} + export function androidWorkspaceNavigationState( state: NavigationState, destination?: AndroidWorkspaceDestination, @@ -31,3 +43,34 @@ export function androidWorkspaceNavigationState( ], }; } + +/** + * Resets the root stack to a workspace list or detail. Always reads and targets the root + * navigator: the sidebar and rail render in the root layout, where `useNavigation()` is the + * container, and a container dispatch runs on the deepest focused navigator. With Settings + * or New task open, that is their nested stack, which rejects the reset. + */ +export function resetAndroidWorkspace( + navigation: WorkspaceNavigation, + destination?: AndroidWorkspaceDestination, + reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"], +) { + let root = navigation; + for (let parent = root.getParent(); parent; parent = root.getParent()) root = parent; + const state = root.getRootState?.() ?? root.getState(); + if (!state) return; + // Same shape as `CommonActions.reset`, which would pull React Native into node tests. + navigation.dispatch({ + type: "RESET", + payload: androidWorkspaceNavigationState(state, destination, reviewers), + target: state.key, + }); +} + +// In the split view these destinations swap the detail pane beside the sidebar instead of +// stacking over Home, so the default slide-in would show a layer that is not there. +export function androidWorkspaceDestinationAnimation(routeName: string, usesSplitView: boolean) { + return usesSplitView && (routeName === "Thread" || routeName === "PullRequest") + ? ("none" as const) + : undefined; +} diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 52415e799742..47f225012bfe 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -420,30 +420,15 @@ describe("deriveWorkspacePaneLayout", () => { describe("alignListPaneToHinge", () => { const layout = deriveLayout({ width: 852, height: 880 }); - it("ends the list pane at a book-posture hinge after the rail", () => { + it("ends the list pane at a book-posture hinge", () => { expect( - alignListPaneToHinge({ - layout, - hinge: { x: 426, width: 0 }, - viewportWidth: 852, - leadingWidth: 96, - }), - ).toMatchObject({ listPaneWidth: 330, listPaneGap: 0 }); + alignListPaneToHinge({ layout, hinge: { x: 426, width: 0 }, viewportWidth: 852 }), + ).toMatchObject({ listPaneWidth: 426, listPaneGap: 0 }); }); it("keeps the default split when either side of the hinge would be too narrow", () => { - const narrowList = { - layout, - hinge: { x: 300, width: 0 }, - viewportWidth: 852, - leadingWidth: 96, - }; - const narrowDetail = { - layout, - hinge: { x: 600, width: 0 }, - viewportWidth: 852, - leadingWidth: 0, - }; + const narrowList = { layout, hinge: { x: 250, width: 0 }, viewportWidth: 852 }; + const narrowDetail = { layout, hinge: { x: 600, width: 0 }, viewportWidth: 852 }; expect(alignListPaneToHinge(narrowList)).toBe(layout); expect(alignListPaneToHinge(narrowDetail)).toBe(layout); }); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 2fd4acc32e3f..f3d4c40c2c3e 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -14,6 +14,9 @@ const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; export const CHAT_CONTENT_MAX_WIDTH = 960; +// Android reading width for the thread feed, composer, and full-width thread list. +// Phones are narrower; foldables in portrait and the keyboard-open split view are not. +export const ANDROID_READING_MAX_WIDTH = 640; // min-h-8 uses the 14px rem configured in metro.config.js. export const THREAD_WORK_ROW_MIN_HEIGHT = 28; @@ -136,26 +139,18 @@ export interface VerticalHinge { readonly width: number; } -/** - * Moves the list/detail split onto a book-posture hinge so neither pane straddles the crease. - * `leadingWidth` is chrome left of the list pane, such as the Android navigation rail. - */ +/** Moves the list/detail split onto a book-posture hinge so neither pane straddles the crease. */ export function alignListPaneToHinge(input: { readonly layout: Layout; readonly hinge: VerticalHinge | null; readonly viewportWidth: number; - readonly leadingWidth: number; }): Layout { const { layout, hinge } = input; if (!hinge || !layout.usesSplitView) return layout; - const listPaneWidth = hinge.x - input.leadingWidth; - if ( - listPaneWidth < SPLIT_SIDEBAR_MIN_WIDTH || - input.viewportWidth - hinge.x - hinge.width < 320 - ) { + if (hinge.x < SPLIT_SIDEBAR_MIN_WIDTH || input.viewportWidth - hinge.x - hinge.width < 320) { return layout; } - return { ...layout, listPaneWidth, listPaneGap: hinge.width }; + return { ...layout, listPaneWidth: hinge.x, listPaneGap: hinge.width }; } /** diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts index c133a57d8a25..fdf9061747e3 100644 --- a/apps/mobile/src/native/useOpenPullRequest.ts +++ b/apps/mobile/src/native/useOpenPullRequest.ts @@ -2,8 +2,7 @@ import { useNavigation, useRoute, StackActions } from "@react-navigation/native" import { nativePullRequestDestination } from "../lib/pullRequestNavigation"; import { useCallback } from "react"; import { Platform } from "react-native"; -import { CommonActions } from "@react-navigation/native"; -import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation"; +import { resetAndroidWorkspace } from "../lib/android-workspace-navigation"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useThreadShell } from "../state/entities"; import { tryOpenExternalUrl } from "../lib/openExternalUrl"; @@ -45,11 +44,7 @@ export function useOpenPullRequest(options?: { ); if (!params) return tryOpenExternalUrl(url, "pull-request"); if (Platform.OS === "android") - navigation.dispatch((state) => - CommonActions.reset( - androidWorkspaceNavigationState(state, { name: "PullRequest", params }), - ), - ); + resetAndroidWorkspace(navigation, { name: "PullRequest", params }); else if (replace) navigation.dispatch(StackActions.replace("PullRequest", params)); else navigation.navigate("PullRequest", params); return true;