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 deleted file mode 100644 index e13c1fbbae501..0000000000000 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ /dev/null @@ -1,184 +0,0 @@ -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 -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.sp -import expo.modules.kotlin.AppContext -import expo.modules.kotlin.modules.Module -import expo.modules.kotlin.modules.ModuleDefinition -import expo.modules.kotlin.viewevent.EventDispatcher -import expo.modules.kotlin.views.ExpoView - -class T3WorkspaceNavigationModule : Module() { - override fun definition() = ModuleDefinition { - Name("T3WorkspaceNavigation") - View(T3WorkspaceNavigationView::class) { - 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 -> - view.containerColor = value - } - Prop("selectedColor") { view: T3WorkspaceNavigationView, value: Int -> - view.selectedColor = value - } - Prop("indicatorColor") { view: T3WorkspaceNavigationView, value: Int -> - view.indicatorColor = value - } - Prop("unselectedColor") { view: T3WorkspaceNavigationView, value: Int -> - view.unselectedColor = value - } - Events("onTabSelected") - } - } -} - -/** One Material3 selection surface moves from below the list to its leading edge. */ -class T3WorkspaceNavigationView( - context: Context, - appContext: AppContext -) : ExpoView(context, appContext) { - // Compose must remeasure native children when Yoga resizes the folded window. - override val shouldUseAndroidLayout = true - - 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) - var selectedColor by mutableStateOf(0) - var indicatorColor by mutableStateOf(0) - var unselectedColor by mutableStateOf(0) - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - // Fabric measures the replacement bar before attaching it on a fold. - // Compose needs an attached window to obtain its lifecycle recomposer. - if (!isAttachedToWindow) { - setMeasuredDimension( - MeasureSpec.getSize(widthMeasureSpec), - MeasureSpec.getSize(heightMeasureSpec) - ) - return - } - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } - - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - if (isAttachedToWindow) super.onLayout(changed, left, top, right, bottom) - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - requestLayout() - } - - init { - addView( - ComposeView(context).apply { - setViewCompositionStrategy( - ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool - ) - 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), - ), - ) - } - } - } - } - } - }, - LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - ) - } - - @Composable - private fun tabIcon(tab: String) { - val drawable = if (tab == "threads") { - R.drawable.t3_chats - } else { - R.drawable.t3_pull_requests - } - Icon( - painter = painterResource(drawable), - contentDescription = if (tab == "threads") "Chats" else "Pull requests", - ) - } -} diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chats.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chats.xml deleted file mode 100644 index 20321f29bb9da..0000000000000 --- a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chats.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_pull_requests.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_pull_requests.xml deleted file mode 100644 index 98c043c9d7fda..0000000000000 --- a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_pull_requests.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index 08ac3e0785344..db4b759ccb5a3 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -10,7 +10,6 @@ "android": { "modules": [ "expo.modules.t3nativecontrols.T3NativeControlsModule", - "expo.modules.t3nativecontrols.T3WorkspaceNavigationModule", "expo.modules.t3nativecontrols.T3KeyboardCommandsModule" ] } diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 1ae2464e0a771..b40fa7065e44e 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -1,5 +1,6 @@ import { useState, type ReactNode } from "react"; import { ActivityIndicator, View } from "react-native"; +import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import type { AppSymbolName } from "./AppSymbol"; import { AppText as Text } from "./AppText"; @@ -41,11 +42,16 @@ export function AndroidScreenHeader(props: { readonly onBack?: () => void; readonly embedded?: boolean; readonly hideBottomBorder?: boolean; + /** One row: the subtitle follows the title, for screens short on height. */ + readonly compact?: boolean; + /** Hides the toolbar row, keeping only the status bar inset, e.g. while reading history. */ + readonly collapsed?: boolean; }) { const titleTypography = useScaledTextRole("title"); const subtitleTypography = useScaledTextRole("label"); const { height: materialToolbarHeight, ...headerPadding } = useMaterialToolbarLayout( props.embedded, + props.compact, ); const [headerWidth, setHeaderWidth] = useState(0); const { visibleActions, overflowActions } = splitAndroidHeaderActions( @@ -54,94 +60,120 @@ export function AndroidScreenHeader(props: { ); return ( - setHeaderWidth(event.nativeEvent.layout.width)} className="border-b border-header-border bg-header px-2" style={{ ...headerPadding, + ...(props.collapsed ? { paddingBottom: 0 } : null), borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > - - {props.onBack ? ( - - ) : null} + {props.collapsed ? null : ( + + {props.onBack ? ( + + ) : null} - {props.leading} + {props.leading} - - {props.titleContent ?? ( - - {props.title} - + + {props.titleContent ?? ( + + {props.title} + + )} + {props.subtitle ? ( + + {props.subtitle} + + ) : null} + + + {visibleActions.map((action) => + action.loading ? ( + + + + ) : ( + + ), )} - {props.subtitle ? ( - 0 ? ( + ({ + id: String(index), + title: action.accessibilityLabel, + attributes: { + disabled: Boolean(action.disabled), + destructive: action.destructive, + state: action.selected ? "on" : undefined, + }, + }))} + onPressAction={({ nativeEvent }) => + overflowActions[Number(nativeEvent.event)]?.onPress() + } > - {props.subtitle} - + {(open) => ( + + )} + ) : null} - - - {visibleActions.map((action) => - action.loading ? ( - - - - ) : ( - - ), - )} - {overflowActions.length > 0 ? ( - ({ - id: String(index), - title: action.accessibilityLabel, - attributes: { - disabled: Boolean(action.disabled), - destructive: action.destructive, - state: action.selected ? "on" : undefined, - }, - }))} - onPressAction={({ nativeEvent }) => - overflowActions[Number(nativeEvent.event)]?.onPress() - } - > - {(open) => ( - - )} - - ) : null} - {props.trailing} - - + {props.trailing} + + )} + ); } +const HEADER_COLLAPSE_TRANSITION = LinearTransition.duration(180); +const HEADER_ROW_ENTERING = FadeIn.duration(160); +const HEADER_ROW_EXITING = FadeOut.duration(100); + export function AndroidSheetHeader( props: Omit[0], "embedded">, ) { diff --git a/apps/mobile/src/components/MaterialScreenContent.tsx b/apps/mobile/src/components/MaterialScreenContent.tsx index 44d4cc093b6d7..15a2d7ef9d416 100644 --- a/apps/mobile/src/components/MaterialScreenContent.tsx +++ b/apps/mobile/src/components/MaterialScreenContent.tsx @@ -1,15 +1,12 @@ import type { ReactNode } from "react"; import { Platform, View } from "react-native"; -/** Keeps the header surface visible behind rounded Android content corners. */ +/** Android content surface below the header: flat, square edges, full width. */ export function MaterialScreenContent({ children, - insetHorizontal = false, fitToContents = false, }: { readonly children: ReactNode; - /** Match the master-list gutters for secondary panes, not full-width content. */ - readonly insetHorizontal?: boolean; /** Allow native form sheets to measure their content instead of filling a fixed detent. */ readonly fitToContents?: boolean; }) { @@ -18,12 +15,11 @@ export function MaterialScreenContent({ return ( {children} diff --git a/apps/mobile/src/components/ScreenHeader.android.tsx b/apps/mobile/src/components/ScreenHeader.android.tsx index 333afbce7cccb..84f03fe36e3b6 100644 --- a/apps/mobile/src/components/ScreenHeader.android.tsx +++ b/apps/mobile/src/components/ScreenHeader.android.tsx @@ -158,6 +158,8 @@ export function ScreenHeader(props: ScreenHeaderProps) { onBack={props.onBack} leading={props.sidebar !== false ? : undefined} hideBottomBorder={props.hideBottomBorder} + compact={props.compact} + collapsed={props.collapsed} actions={ search ? [ diff --git a/apps/mobile/src/components/ScreenHeader.types.ts b/apps/mobile/src/components/ScreenHeader.types.ts index 6d308aff39798..f947908a455ad 100644 --- a/apps/mobile/src/components/ScreenHeader.types.ts +++ b/apps/mobile/src/components/ScreenHeader.types.ts @@ -69,6 +69,10 @@ export interface ScreenHeaderProps { readonly onPress?: () => void; }; readonly hideBottomBorder?: boolean; + /** Android: one title row with the subtitle inline. iOS ignores it. */ + readonly compact?: boolean; + /** Android: hide the toolbar row, e.g. while reading thread history. iOS ignores it. */ + readonly collapsed?: boolean; readonly trailing?: ReactNode; /** Search scenes use sheet colors on iOS and header colors on Android. */ readonly matchSearchSurface?: boolean; diff --git a/apps/mobile/src/components/useMaterialToolbarLayout.ts b/apps/mobile/src/components/useMaterialToolbarLayout.ts index f68a6eec7a46b..6bf94a6a06b81 100644 --- a/apps/mobile/src/components/useMaterialToolbarLayout.ts +++ b/apps/mobile/src/components/useMaterialToolbarLayout.ts @@ -4,8 +4,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; -/** Shared Android header geometry; class-based spacing uses the app's 14dp rem. */ -export function useMaterialToolbarLayout(embedded = false) { +/** + * Shared Android header geometry; class-based spacing uses the app's 14dp rem. + * `singleLine` sizes the toolbar for a title row with an inline subtitle. + */ +export function useMaterialToolbarLayout(embedded = false, singleLine = false) { const insets = useSafeAreaInsets(); const title = useScaledTextRole("title"); const subtitle = useScaledTextRole("label"); @@ -13,7 +16,9 @@ export function useMaterialToolbarLayout(embedded = false) { const { fontScale } = useWindowDimensions(); return { height: Math.ceil( - Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), + singleLine + ? Math.max(48, 48 * scale, title.lineHeight * fontScale + 1) + : Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), ), paddingTop: embedded ? 8 * scale : Math.max(insets.top, 12 * scale), paddingBottom: 7 * scale, diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index ca9adeaea1650..5392daac75833 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -425,7 +425,7 @@ function FilesToolbarBottomFade() { export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { useAdaptiveWorkspacePaneRole("inspector"); const navigation = useNavigation(); - const { fileInspector, layout, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { fileInspector, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const [searchQuery, setSearchQuery] = useState(""); const { themeAppearance: highlightTheme } = useAppearancePreferences(); const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( @@ -563,7 +563,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { onRefresh={entriesQuery.refresh} onBack={handleReturnToThread} /> - + ) : null} - {fileTree} + {fileTree} ); } diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index 423c76d91ea3c..65ea1585c8c08 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -43,11 +43,7 @@ export function AndroidHomeFabLayout(props: ComponentProps ) : null} diff --git a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx index 698ea53dcd042..76682c09e97ea 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.shared.tsx @@ -4,7 +4,6 @@ import type { ReactNode } from "react"; export function AndroidHomeFabLayout(props: { readonly onStartNewTask: () => void; readonly children: ReactNode; - readonly sidebar?: boolean; readonly disabled?: boolean; }) { return <>{props.children}; diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index cd0e1c120fc00..b64949c4b3daa 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/EmptyState"; import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; @@ -293,6 +298,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; // UIKit's column safe area already includes its bottom toolbar. const iosBottomClearance = Math.max( columnMetrics?.safeArea.bottom ?? insets.bottom, @@ -936,11 +952,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( {v2ListEmpty} @@ -1041,11 +1053,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( {/* Shared with the iPad sidebar: cells are reused across data rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated @@ -1094,8 +1102,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/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 0f074105d2c58..ba99623bbbb7d 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -12,8 +12,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { MenuAction } from "@react-native-menu/menu"; import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; -import { CompactBrandTitle } from "../../components/CompactBrandTitle"; import { MaterialFloatingActionButton } from "../../components/MaterialFloatingActionButton"; +import { MaterialIconButton } from "../../components/MaterialIconButton"; import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; import { ControlPillMenu } from "../../components/ControlPill"; import { MaterialSearchField } from "../../components/MaterialSearchField"; @@ -22,8 +22,13 @@ import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; import { useWorkspaceState } from "../../state/workspace"; import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; +import { AndroidWorkspaceTabSwitch } from "../workspace/AndroidWorkspaceTabSwitch"; -/** One toolbar height for the compact list and expanded sidebar, including search. */ +/** + * One toolbar height for the compact list and expanded sidebar, including search. + * Both lead with the Chats/PRs switch. The sidebar variant also keeps Filter and + * New thread in the header, so nothing floats over the list. + */ export function MaterialThreadListToolbar(props: { readonly searchQuery: string; readonly onSearchQueryChange: (query: string) => void; @@ -33,6 +38,8 @@ export function MaterialThreadListToolbar(props: { readonly onOpenSettings: () => void; readonly onOpenEnvironments: () => void; readonly sidebar?: boolean; + /** Sidebar only: the header's New thread action. */ + readonly onStartNewTask?: () => void; readonly onLayout?: (event: LayoutChangeEvent) => void; readonly onRequestVisibility?: () => void; }) { @@ -105,7 +112,14 @@ export function MaterialThreadListToolbar(props: { } style={headerPadding} > - + {searching ? ( <> {searchField} + ) : props.sidebar ? ( + <> + + + + + {/* Wraps below the switch when large text leaves no room on one row. */} + + + {state.hasConnections ? ( + + {(open) => ( + + )} + + ) : null} + + {state.hasConnections && props.onStartNewTask ? ( + + ) : null} + + ) : ( <> - {/* Match the visible inset of the trailing 48dp icon button. */} - + + } /> {/* Keep the filter above the New thread FAB at every text size. */} - {state.hasConnections ? ( + {state.hasConnections && !props.sidebar ? ( {(open) => ( diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index 1e461e3f0d7a8..c59ec4035535e 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -104,6 +104,8 @@ export function WorkspaceConnectionTitle(props: { readonly statusOffset?: number; /** Space available beside the native header actions. */ readonly maxWidth?: number; + /** Show only the status icon; the label stays as its accessibility label. */ + readonly iconOnly?: boolean; }) { const status = useDelayedConnectionStatus(); const size = props.size ?? "navbar"; @@ -147,13 +149,15 @@ export function WorkspaceConnectionTitle(props: { type="monochrome" /> )} - - {status.label} - + {props.iconOnly ? null : ( + + {status.label} + + )} ); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index b2981f63e2551..3a07f6a8d3589 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -23,6 +23,7 @@ import { type ReactNode, } from "react"; import { Platform, useWindowDimensions, View } from "react-native"; +import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { runOnJS, useAnimatedStyle, @@ -33,6 +34,7 @@ import Animated, { import { AsyncResult } from "effect/reactivity"; import { + ANDROID_LIST_PANE_MIN_WIDTH, deriveFileInspectorPaneLayout, deriveLayout, deriveWorkspacePaneLayout, @@ -56,12 +58,6 @@ import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; -import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; -import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; -import { - AndroidWorkspaceNavigation, - ANDROID_WORKSPACE_RAIL_WIDTH, -} from "../workspace/AndroidWorkspaceNavigation"; import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; import { HomeListOptionsProvider } from "../home/home-list-options"; @@ -98,6 +94,13 @@ interface AdaptiveWorkspaceContextValue { readonly toggleAuxiliaryPane: () => void; readonly togglePrimarySidebar: () => void; readonly setAuxiliaryPaneWidth: (width: number) => void; + /** + * Android split view while the thread composer has the soft keyboard: the + * sidebar steps aside and the thread header drops to one line. + */ + readonly composerKeyboardActive: boolean; + /** The thread composer reports its focus so the layout can make room for typing. */ + readonly setComposerEditing: (editing: boolean) => void; } const compactLayout = deriveLayout({ width: 0, height: 0 }); @@ -124,6 +127,8 @@ const AdaptiveWorkspaceContext = createContext({ toggleAuxiliaryPane: () => undefined, togglePrimarySidebar: () => undefined, setAuxiliaryPaneWidth: () => undefined, + composerKeyboardActive: false, + setComposerEditing: () => undefined, }); export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { @@ -257,12 +262,12 @@ function AdaptiveWorkspaceLayoutContent( const windowDimensions = useWindowDimensions(); const nativeMetrics = useNativeLayoutMetrics(); const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; - const { scale: androidControlScale } = useAndroidControlSizing(); const pathname = props.pathname; const navigation = useNavigation(); const activeRoleOwner = useRef(null); const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); - const showPrimarySidebar = pathname === "/" || primarySidebarPreferredVisible; + const [composerEditing, setComposerEditing] = useState(false); + const softKeyboardVisible = useKeyboardState((state) => state.isVisible); const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true); const [supplementaryPanePreferredWidth, setSupplementaryPanePreferredWidth] = useState< number | null @@ -274,19 +279,22 @@ function AdaptiveWorkspaceLayoutContent( const [primarySidebarSearchQuery, setPrimarySidebarSearchQuery] = useState(""); const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = useState(null); - const baseLayout = useMemo( - () => deriveLayout({ width: viewportWidth, height, nativeMetrics }), + const layout = useMemo( + () => + deriveLayout({ + width: viewportWidth, + height, + nativeMetrics, + listPaneMinWidth: Platform.OS === "android" ? ANDROID_LIST_PANE_MIN_WIDTH : undefined, + }), [height, viewportWidth, nativeMetrics], ); - const layout = baseLayout; - // The rail consumes real space before either pane; inspectors and the detail - // navigator must use the remaining viewport rather than clipping behind it. - const shouldRenderNavigationRail = Platform.OS === "android" && layout.usesSplitView; - const navigationRailWidth = - shouldRenderNavigationRail && showPrimarySidebar - ? ANDROID_WORKSPACE_RAIL_WIDTH * Math.max(1, androidControlScale) - : 0; - const width = viewportWidth - navigationRailWidth; + // Keyed on the soft keyboard, not focus alone: a hardware keyboard leaves room + // for the sidebar, and Back closes the keyboard while the editor keeps focus. + const composerKeyboardActive = + Platform.OS === "android" && layout.usesSplitView && composerEditing && softKeyboardVisible; + const showPrimarySidebar = + pathname === "/" || (primarySidebarPreferredVisible && !composerKeyboardActive); // In split layouts the sidebar IS the thread list — it renders on every // route, including Home (which shows an empty-detail pane instead of the // compact list). @@ -295,14 +303,20 @@ function AdaptiveWorkspaceLayoutContent( () => 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"); @@ -318,7 +332,7 @@ function AdaptiveWorkspaceLayoutContent( () => deriveWorkspacePaneLayout({ layout, - viewportWidth: width, + viewportWidth, primarySidebarPreferredVisible: showPrimarySidebar, auxiliaryPanePreferredVisible, auxiliaryPaneRole, @@ -330,7 +344,7 @@ function AdaptiveWorkspaceLayoutContent( auxiliaryPanePreferredWidth, layout, showPrimarySidebar, - width, + viewportWidth, ], ); const activeThread = parseActiveThreadPath(pathname); @@ -390,19 +404,30 @@ function AdaptiveWorkspaceLayoutContent( if (pathname === "/") { return; } + // The sidebar returns with the keyboard; keep the user's preference intact. + if (composerKeyboardActive) { + void KeyboardController.dismiss(); + return; + } if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); setPrimarySidebarPreferredVisible(true); return; } setPrimarySidebarPreferredVisible((current) => !current); - }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible, pathname]); + }, [ + composerKeyboardActive, + panes.primarySidebarSuppressedByAuxiliary, + panes.primarySidebarVisible, + pathname, + ]); const revealPrimarySidebar = useCallback(() => { + if (composerKeyboardActive) void KeyboardController.dismiss(); if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); } setPrimarySidebarPreferredVisible(true); - }, [panes.primarySidebarSuppressedByAuxiliary]); + }, [composerKeyboardActive, panes.primarySidebarSuppressedByAuxiliary]); const handleToggleSidebarCommand = useCallback(() => { togglePrimarySidebar(); return true; @@ -552,7 +577,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 @@ -561,19 +585,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 })); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ // Fading a UIKit glass ancestor can leave remounted header effects missing. // The clipped width already reveals and hides the iOS sidebar. @@ -589,13 +609,7 @@ function AdaptiveWorkspaceLayoutContent( // frames during sidebar/inspector transitions. 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(); @@ -666,9 +680,12 @@ function AdaptiveWorkspaceLayoutContent( toggleAuxiliaryPane, togglePrimarySidebar, setAuxiliaryPaneWidth, + composerKeyboardActive, + setComposerEditing, }), [ activateAuxiliaryPaneRole, + composerKeyboardActive, fileInspector, handleSelectThread, layout, @@ -709,17 +726,6 @@ function AdaptiveWorkspaceLayoutContent( testID="adaptive-workspace-layout" 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 ? ( - - - + diff --git a/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx b/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx index b7a3de14bde41..359ff2cb9309c 100644 --- a/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx +++ b/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx @@ -2,13 +2,13 @@ import { useNavigation, useNavigationState } from "@react-navigation/native"; import { useCallback, useEffect, useRef } from "react"; import { BackHandler, Keyboard, View, type TextInputInstance } from "react-native"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { CompactBrandTitle } from "../../components/CompactBrandTitle"; import { MaterialSearchField } from "../../components/MaterialSearchField"; import { WorkspaceConnectionTitle } from "../home/WorkspaceConnectionTitle"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; +import { AndroidWorkspaceTabSwitch } from "../workspace/AndroidWorkspaceTabSwitch"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -/** PR search shares the thread list's native Android header placement. */ +/** PR search shares the thread list's Android header placement, led by the Chats/PRs switch. */ export function MaterialPullRequestToolbar(props: { readonly onRequestVisibility?: () => void }) { const navigation = useNavigation(); const workspace = useWorkspaceTabs()!; @@ -36,6 +36,11 @@ export function MaterialPullRequestToolbar(props: { readonly onRequestVisibility recordScrollOffset("pullRequests", tab.searchEntryOffset); dispatch({ type: "cancel", tab: "pullRequests" }); }, [dispatch, recordScrollOffset, tab.searchEntryOffset]); + const openEnvironments = () => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }); useEffect(() => { if (!searching || !listFocused) return; const subscription = BackHandler.addEventListener("hardwareBackPress", () => { @@ -63,22 +68,10 @@ export function MaterialPullRequestToolbar(props: { readonly onRequestVisibility onChangeText={(query) => dispatch({ type: "query", tab: "pullRequests", query })} /> ) : ( - - - } - onPress={() => - navigation.navigate("SettingsSheet", { - screen: "SettingsContent", - params: { screen: "SettingsEnvironments" }, - }) - } - /> + // Cancel the header's title inset so the switch lines up with the Chats header. + + + ) } diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index f68a19bbf28e7..4ab1abcb8b842 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -505,15 +505,8 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps setViewportHeight(event.nativeEvent.layout.height)} > {(["overview", "guide", "activity"] as const) @@ -761,7 +754,6 @@ export function PullRequestFileRouteScreen({ readonly focus?: GuideDiffFocus; } >) { - const { layout } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom); @@ -1101,13 +1093,8 @@ export function PullRequestFileRouteScreen({ ) : null} {loadingDiff || result.error ? ( diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 0ca575f938ff8..68cf0c8f769c3 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -439,7 +439,7 @@ function ReviewFileNavigator({ )} - {fileList} + {fileList} ); } diff --git a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx index 58a8931b84eaa..aaa694121e7af 100644 --- a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx @@ -22,7 +22,6 @@ import { environmentThreadDetails } from "../../state/threads"; import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { SubagentRow } from "./SubagentRow"; import { MaterialScreenContent } from "../../components/MaterialScreenContent"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -33,7 +32,6 @@ export function useThreadTurnSubagents(target: AgentsTarget): ThreadTurnSubagent } export function ThreadAgentsSheet({ route }: StaticScreenProps) { - const { layout } = useAdaptiveWorkspaceLayout(); const target = route.params; const navigation = useNavigation(); const insets = useSafeAreaInsets(); @@ -118,9 +116,7 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) { navigation.goBack()} /> - - {content} - + {content} ); } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index a45f319423fe2..6c89d868a28ac 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,4 +1,6 @@ import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { WorkingTimer } from "./floating-working-control"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { useAtomValue } from "@effect/atom-react"; @@ -203,7 +205,10 @@ export interface ThreadComposerProps { readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; + /** True while the composer renders its multi-row card rather than the one-row capsule. */ readonly onExpandedChange?: (expanded: boolean) => void; + /** Turn timer shown in the focused one-row composer of Android split view. */ + readonly activeWorkStartedAt?: string | null; /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ readonly onEditorFocusChange?: (focused: boolean) => void; } @@ -532,8 +537,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const isVoiceInputPresented = voicePresentation.statusLabel !== null; // An open draft stays visible; only a collapsed composer becomes a voice strip. const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; - const showsCompactDictation = isVoiceInputPresented && !isExpanded; - const isToolbarVisible = isExpanded || isVoiceInputPresented; + const { layout, setComposerEditing } = useAdaptiveWorkspaceLayout(); + // Wide Android layouts keep the focused composer on one row, so the soft + // keyboard leaves room for the feed. The model picker joins that row. + const staysInline = Platform.OS === "android" && layout.usesSplitView; + const usesCard = isExpanded && !staysInline; + // The one-row composer grows with its text up to the card's limit, then scrolls. + const [draftTextHeight, setDraftTextHeight] = useState(0); + const inlineEditorHeight = Math.min(160, Math.max(36, Math.ceil(draftTextHeight))); + const showsCompactDictation = isVoiceInputPresented && !usesCard; + const isToolbarVisible = usesCard || isVoiceInputPresented; const attachmentBlockReason = composerAttachmentUploadBlockReason({ environmentId: props.environmentId, attachments: props.draftAttachments, @@ -557,8 +570,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // Keep the feed inset aligned with the card or compact dictation strip. useEffect(() => { - onExpandedChange?.(isExpanded); - }, [isExpanded, onExpandedChange]); + onExpandedChange?.(usesCard); + }, [usesCard, onExpandedChange]); + useEffect(() => () => setComposerEditing(false), [setComposerEditing]); const onPressPreview = useCallback( (source: FilePreviewSource) => { @@ -597,17 +611,24 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = useCallback(() => { setIsFocused(true); - onExpandedChange?.(true); + onExpandedChange?.(!staysInline); onEditorFocusChange?.(true); - }, [onEditorFocusChange, onExpandedChange]); + setComposerEditing(true); + }, [onEditorFocusChange, onExpandedChange, setComposerEditing, staysInline]); const handleBlur = useCallback(() => { setIsFocused(false); + setComposerEditing(false); if (!settingsSheetPresentation.keepsComposerExpanded) { onExpandedChange?.(false); } onEditorFocusChange?.(false); - }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); + }, [ + onEditorFocusChange, + onExpandedChange, + setComposerEditing, + settingsSheetPresentation.keepsComposerExpanded, + ]); const handleSend = useCallback( async (followUp?: ActiveTurnComposerAction) => { if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; @@ -774,6 +795,72 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer [navigation, settingsSheetPresentation.onStackTransitionsFinished], ); + // Removable attachments: inside the card, or above the focused one-row composer. + const editingAttachments = ( + <> + {queuedEdit !== null && queuedEdit.existingAttachments.length > 0 ? ( + + + + ) : null} + {stripAttachments.length > 0 ? ( + + undefined : props.onRemoveDraftImage} + onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} + onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} + onPressDocument={ + voiceInput.isBusy + ? undefined + : (attachment) => + openDraftDocument({ + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }) + } + /> + + ) : null} + + ); + + // The card's toolbar and the one-row composer share this picker. + const modelControl = ( + + ( + + )} + label={currentModelOption?.label ?? currentModelSelection.model} + maxWidth="100%" + onPress={openSettings} + /> + + ); + return ( + {/* The one-row composer keeps removable attachments above its row. */} + {isExpanded && !usesCard ? editingAttachments : null} - - ( - - )} - label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth="100%" - onPress={openSettings} - /> - + {modelControl} )} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 1707debb95eec..3726275cdfe74 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -96,7 +96,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"; @@ -218,6 +222,8 @@ export interface ThreadDetailScreenProps { readonly dispatchingMessageId: MessageId | null; readonly serverConfig: T3ServerConfig | null; readonly layoutVariant?: LayoutVariant; + /** True while the feed is scrolled away from its latest message. */ + readonly onReadingHistoryChange?: (readingHistory: boolean) => void; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onOpenConnectionEditor: () => void; @@ -493,7 +499,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const pendingBackgroundWork = presentPendingBackgroundWork( props.selectedThread.pendingBackgroundTasks, ); - const floatingStatus = ((): FloatingWorkingStatus | null => { + const pillStatus = ((): FloatingWorkingStatus | null => { const connectionStatus = connectionFloatingStatus({ connectionError: props.connectionError, connectionState: props.connectionStateLabel, @@ -561,6 +567,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } return null; })(); + // The focused one-row composer of Android split view carries the timer itself, + // so the pill row stops taking feed height while the keyboard is up. + const composerShowsTimer = + Platform.OS === "android" && + props.layoutVariant === "split" && + composerFocused && + pillStatus?.kind === "working"; + const floatingStatus = composerShowsTimer ? null : pillStatus; const showWorkingControl = floatingStatus !== null; // Connection and working status occupy the same space. Keep the feed inset // stable when reconnecting hands off to syncing and then to a running turn. @@ -843,7 +857,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, ); @@ -1091,6 +1110,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }, [freeze, scrollMessageToEnd]); const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; + const onReadingHistoryChange = props.onReadingHistoryChange; + useEffect(() => { + onReadingHistoryChange?.(showScrollToEndButton); + }, [onReadingHistoryChange, showScrollToEndButton]); const { themeAppearance } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; @@ -1482,6 +1505,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} onExpandedChange={setComposerExpanded} + activeWorkStartedAt={ + composerShowsTimer && pillStatus?.kind === "working" + ? pillStatus.startedAt + : null + } onEditorFocusChange={handleComposerFocusChange} /> diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx index 5da9c93a7f9dd..1951032deae9d 100644 --- a/apps/mobile/src/features/threads/ThreadHeader.tsx +++ b/apps/mobile/src/features/threads/ThreadHeader.tsx @@ -16,10 +16,13 @@ export function ThreadHeader( readonly onToggleInspector: () => void; readonly onOpenGitInspector: () => void; readonly onOpenFilesInspector: () => void; + /** Android: the feed is scrolled back into history, so the toolbar steps aside. */ + readonly collapsed?: boolean; }, ) { const returnToList = useWorkspaceListNavigation("threads"); - const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { layout, panes, toggleAuxiliaryPane, composerKeyboardActive } = + useAdaptiveWorkspaceLayout(); const { onOpenTerminal, onMergeBack } = props.gitControls; const native = useThreadHeaderOptions(props); const androidHeaderActions = useMemo>(() => { @@ -99,6 +102,8 @@ export function ThreadHeader( onBack={layout.usesSplitView ? undefined : returnToList} actions={androidHeaderActions} hideBottomBorder + compact={composerKeyboardActive} + collapsed={props.collapsed} /> {native.fallback} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 92cc958a8c90b..cfb0377946544 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,4 +1,3 @@ -import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { computeThreadMoveAvailability } from "./threadOrder"; @@ -52,7 +51,6 @@ import { import { SidebarHeaderActions } from "./sidebar-header-actions"; import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar"; import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; -import { useMaterialFabScroll } from "../home/MaterialFabScrollContext"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; import { SidebarNavigationShell } from "./sidebar-navigation-shell"; @@ -96,6 +94,8 @@ interface ThreadNavigationSidebarProps { readonly onSearchQueryChange: (query: string) => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onRequestVisibility: () => void; + /** Android header action; iOS starts threads from its native toolbar. */ + readonly onStartNewTask: () => void; readonly searchQuery: string; } @@ -136,7 +136,6 @@ function ThreadNavigationSidebarPane( const drawerColor = materialTheme["--color-drawer"]; const insets = useSafeAreaInsets(); - const { fabClearance } = useAndroidControlSizing(); const projects = useProjects(); const threads = useNavigationThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); @@ -591,9 +590,7 @@ function ThreadNavigationSidebarPane( const handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); - const onMaterialFabScroll = useMaterialFabScroll(); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: onMaterialFabScroll, onScrollBeginDrag: handleScrollBeginDrag, }); // The sticky header's project shells and search maps feed row props, so @@ -986,12 +983,8 @@ function ThreadNavigationSidebarPane( Platform.OS === "android" ? { marginTop: stickyHeaderHeight, - marginHorizontal: 4, paddingBottom: insets.bottom, backgroundColor: drawerColor, - borderTopLeftRadius: 28, - borderTopRightRadius: 28, - overflow: "hidden", } : { paddingBottom: insets.bottom } } @@ -1016,7 +1009,7 @@ function ThreadNavigationSidebarPane( { paddingBottom: Platform.OS === "android" - ? Math.max(insets.bottom, 16) + fabClearance - insets.bottom + ? Math.max(insets.bottom, 16) - insets.bottom : 16 + insets.bottom, paddingTop: Platform.OS === "android" ? 6 : topListInset, }, @@ -1047,6 +1040,7 @@ function ThreadNavigationSidebarPane( onOpenSettings={props.onOpenSettings} onOpenEnvironments={props.onOpenEnvironmentSettings} onRequestVisibility={props.onRequestVisibility} + onStartNewTask={props.onStartNewTask} /> ) : ( ) { - const { layout } = useAdaptiveWorkspaceLayout(); const target = route.params; const navigation = useNavigation(); const insets = useSafeAreaInsets(); @@ -453,9 +451,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { navigation.goBack()} /> - - {content} - + {content} ); diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 348f7377724b4..4b2cd75e3e3ea 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -111,11 +111,7 @@ function firstRouteParam(value: string | string[] | undefined): string | null { } function ThreadPane(props: { readonly children: ReactNode }) { - return ( - - {props.children} - - ); + return {props.children}; } function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) { @@ -363,6 +359,8 @@ function ThreadRouteContent( const threadId = firstRouteParam(params.threadId); const routeThreadIdentity = environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null; + // Android collapses the thread toolbar while the feed is scrolled back into history. + const [readingHistory, setReadingHistory] = useState(false); const [inspectorSelection, setInspectorSelection] = useState( () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), ); @@ -1055,6 +1053,7 @@ function ThreadRouteContent( queuedMessages={composer.selectedThreadQueuedMessages} dispatchingMessageId={composer.dispatchingQueuedMessageId} layoutVariant={layout.variant} + onReadingHistoryChange={setReadingHistory} usesAutomaticContentInsets={usesNativeHeaderGlass} onOpenConnectionEditor={handleOpenConnectionEditor} onChangeDraftMessage={composer.onChangeDraftMessage} @@ -1098,6 +1097,7 @@ function ThreadRouteContent( onOpenGitInspector={handleOpenGitInspector} onOpenFilesInspector={handleOpenFilesInspector} onReturnToThread={props.onReturnToThread} + collapsed={Platform.OS === "android" && readingHistory} /> {renderThreadRouteBody()} diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 76cbfd4cf2961..82d96f8e37cfb 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -30,7 +30,6 @@ import { } from "../../../components/AndroidScreenHeader"; import { AndroidAnchoredMenu } from "../../../components/AndroidAnchoredMenu"; import { MaterialScreenContent } from "../../../components/MaterialScreenContent"; -import { useAdaptiveWorkspaceLayout } from "../../layout/AdaptiveWorkspaceLayout"; import { AppText as Text } from "../../../components/AppText"; import { NativeStackScreenOptions, @@ -68,7 +67,6 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { } function GitOverviewSheetContent(props: GitOverviewSheetProps) { - const { layout } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const presentation = props.presentation ?? "sheet"; @@ -573,9 +571,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { /> )} - - {content} - + {content} ); } diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx deleted file mode 100644 index b6cddfbd46391..0000000000000 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { requireNativeView } from "expo"; -import { CommonActions, useNavigation } from "@react-navigation/native"; -import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation"; -import { - Keyboard, - processColor, - View, - type NativeSyntheticEvent, - type ViewProps, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useKeyboardState } from "react-native-keyboard-controller"; - -import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; -import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -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; - readonly containerColor: number; - readonly selectedColor: number; - readonly indicatorColor: number; - readonly unselectedColor: number; - readonly onTabSelected: (event: NativeSyntheticEvent<{ readonly tab: WorkspaceTab }>) => void; -} - -const NativeNavigation = requireNativeView("T3WorkspaceNavigation"); - -function nativeColor(value: string) { - const color = processColor(value); - return typeof color === "number" ? color : 0; -} - -export function AndroidWorkspaceNavigation(props: { - readonly rail?: boolean; - readonly onSelect?: () => void; -}) { - 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; - - return ( - - { - Keyboard.dismiss(); - const tab = event.nativeEvent.tab; - if (tab !== workspace.state.selectedTab) { - workspace.dispatch({ type: "select", tab }); - navigation.dispatch((state) => - CommonActions.reset(androidWorkspaceNavigationState(state)), - ); - } - props.onSelect?.(); - }} - style={rail ? { flex: 1 } : { height: 80 * Math.max(1, scale) }} - /> - - ); -} diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx deleted file mode 100644 index a3da818b21c5f..0000000000000 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx +++ /dev/null @@ -1,9 +0,0 @@ -/** Other clients own their workspace navigation chrome. */ -export function AndroidWorkspaceNavigation(_props: { - readonly rail?: boolean; - readonly onSelect?: () => void; -}) { - 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 711f8cbae5aa9..620f7d8d4abf3 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx @@ -18,7 +18,7 @@ export function AndroidWorkspaceSidebar(props: { - + diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceTabSwitch.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceTabSwitch.tsx new file mode 100644 index 0000000000000..1241c66579b5b --- /dev/null +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceTabSwitch.tsx @@ -0,0 +1,61 @@ +import { Pressable, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors"; +import { useSelectAndroidWorkspaceTab } from "./useAndroidWorkspaceSelection"; +import { useWorkspaceTabs } from "./WorkspaceTabsContext"; +import type { WorkspaceTab } from "./workspace-tabs-state"; + +const TABS = [ + { tab: "threads", label: "Chats", icon: "bubble.left" }, + { tab: "pullRequests", label: "Pull requests", icon: "arrow.triangle.pull" }, +] as const satisfies ReadonlyArray<{ tab: WorkspaceTab; label: string; icon: string }>; + +/** Chats/PRs switch that takes the brand slot in Android list headers. */ +export function AndroidWorkspaceTabSwitch() { + const workspace = useWorkspaceTabs(); + const selectTab = useSelectAndroidWorkspaceTab(); + const { themeVariables } = useAppearancePreferences(); + const { buttonSize, iconSize, scale } = useAndroidControlSizing(); + const colors = androidWorkspaceNavigationColors(themeVariables); + if (workspace === null) return null; + const segmentHeight = Math.round(32 * Math.max(1, scale)); + + return ( + + {TABS.map(({ tab, label, icon }) => { + const selected = workspace.state.selectedTab === tab; + return ( + selectTab(tab)} + className="items-center justify-center" + style={{ width: buttonSize, height: buttonSize }} + > + + + + + ); + })} + + ); +} diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx index 3e6f31cb59024..d1fc6a3be2bf0 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { View } from "react-native"; -import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { EmptyState } from "../../components/EmptyState"; @@ -10,20 +10,21 @@ import { HomeRouteScreen } from "../home/HomeRouteScreen"; import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; import { MaterialPullRequestToolbar } from "../pull-requests/MaterialPullRequestToolbar.android"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { AndroidWorkspaceNavigation } from "./AndroidWorkspaceNavigation"; import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; export function WorkspaceRootScreen() { const { layout } = useAdaptiveWorkspaceLayout(); const workspace = useWorkspaceTabs()!; - const insets = useSafeAreaInsets(); const { fabClearance } = useAndroidControlSizing(); + const insets = useSafeAreaInsets(); const context = useMemo( () => ({ ...workspace, - bottomClearance: workspace.state.selectedTab === "threads" ? fabClearance + 16 : 16, + // The Chats FABs sit above the bottom inset; the list clears both. + bottomClearance: + workspace.state.selectedTab === "threads" ? Math.max(insets.bottom, 16) + fabClearance : 16, }), - [workspace, fabClearance], + [workspace, fabClearance, insets.bottom], ); // Split lists live beside the navigator; Home remains an empty detail pane. @@ -49,21 +50,17 @@ export function WorkspaceRootScreen() { - - - {workspace.state.selectedTab === "threads" ? ( - - ) : ( - <> - - - - - - )} - - - + {/* Chats/PRs switch lives in each list's header; no bottom bar. */} + {workspace.state.selectedTab === "threads" ? ( + + ) : ( + <> + + + + + + )} ); 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 2216ed6b4d631..77e868d7fe72d 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 @@ -35,19 +35,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 on the header", (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", @@ -91,19 +86,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 a43f7e574af2c..f4e6f3a90795c 100644 --- a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts +++ b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts @@ -1,16 +1,12 @@ import { flattenThemeColor, - themeColorWithAlpha, themeColorWithContrast, type MobileThemeVariables, } from "../../lib/mobileTheme"; -/** Both navigation positions keep a visible tonal selection against their actual surface. */ -export function androidWorkspaceNavigationColors(colors: MobileThemeVariables, rail: boolean) { - const header = flattenThemeColor(colors["--color-header"], colors["--color-screen"]); - const containerColor = rail - ? header - : flattenThemeColor(themeColorWithAlpha(colors["--color-header-foreground"], 0.05), header); +/** Selection colors for the Chats/PRs switch, which sits on the header surface. */ +export function androidWorkspaceNavigationColors(colors: MobileThemeVariables) { + const containerColor = flattenThemeColor(colors["--color-header"], colors["--color-screen"]); const indicatorColor = themeColorWithContrast( flattenThemeColor(colors["--color-secondary"], containerColor), containerColor, diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts index 46f49e9c071e5..e81ce8defe657 100644 --- a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts +++ b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts @@ -6,6 +6,7 @@ import { type AndroidWorkspaceDestination, } from "../../lib/android-workspace-navigation"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; +import type { WorkspaceTab } from "./workspace-tabs-state"; export function useAndroidWorkspaceSelection() { const navigation = useNavigation(); @@ -27,3 +28,20 @@ export function useAndroidWorkspaceSelection() { [navigation, dispatch], ); } + +/** Switches Chats/PRs from the bottom bar or the split-view sidebar, closing the open detail. */ +export function useSelectAndroidWorkspaceTab() { + const navigation = useNavigation(); + const workspace = useWorkspaceTabs(); + const dispatch = workspace?.dispatch; + const selectedTab = workspace?.state.selectedTab; + return useCallback( + (tab: WorkspaceTab) => { + Keyboard.dismiss(); + if (tab === selectedTab) return; + dispatch?.({ type: "select", tab }); + navigation.dispatch((state) => CommonActions.reset(androidWorkspaceNavigationState(state))); + }, + [navigation, dispatch, selectedTab], + ); +} diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 7d288b1352a52..c99f5544fc3b8 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -8,6 +8,7 @@ import { deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, + ANDROID_LIST_PANE_MIN_WIDTH, SPLIT_LAYOUT_MIN_HEIGHT, SPLIT_LAYOUT_MIN_WIDTH, } from "./layout"; @@ -158,6 +159,15 @@ describe("deriveLayout", () => { expect(deriveLayout({ width: 1_024, height: 768 }).listPaneWidth).toBe(328); expect(deriveLayout({ width: 1_600, height: 1_000 }).listPaneWidth).toBe(380); }); + + it("gives the Android sidebar header room on narrow split screens only", () => { + const listPaneMinWidth = ANDROID_LIST_PANE_MIN_WIDTH; + // Pixel Fold's inner display: the default rule would give 280. + expect(deriveLayout({ width: 841, height: 701, listPaneMinWidth }).listPaneWidth).toBe(304); + // Wider screens already clear the minimum. + expect(deriveLayout({ width: 1_024, height: 768, listPaneMinWidth }).listPaneWidth).toBe(328); + expect(deriveLayout({ width: 1_600, height: 1_000, listPaneMinWidth }).listPaneWidth).toBe(380); + }); }); describe("deriveWorkspacePaneLayout", () => { diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index ac4cde7789b01..060a3db223c59 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -18,9 +18,15 @@ export const SPLIT_LAYOUT_MIN_HEIGHT = 600; export const SPLIT_SIDEBAR_MIN_WIDTH = 280; const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; +// Android's sidebar header holds the Chats/PRs switch and four 48dp actions on +// one row at the default text size. It replaced an 80dp navigation rail. +export const ANDROID_LIST_PANE_MIN_WIDTH = 304; 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; @@ -93,6 +99,8 @@ export function deriveLayout(input: { readonly width: number; readonly height: number; readonly nativeMetrics?: NativeLayoutMetrics | null; + /** Raises the sidebar's minimum width, e.g. ANDROID_LIST_PANE_MIN_WIDTH. */ + readonly listPaneMinWidth?: number; }): Layout { const { width, height } = input; const metrics = input.nativeMetrics; @@ -130,7 +138,11 @@ export function deriveLayout(input: { usesSplitView: true, listPaneWidth: division?.x ?? - clamp(Math.round(width * 0.32), SPLIT_SIDEBAR_MIN_WIDTH, SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH), + clamp( + Math.round(width * 0.32), + Math.max(SPLIT_SIDEBAR_MIN_WIDTH, input.listPaneMinWidth ?? 0), + SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH, + ), ...(division ? { listPaneGap: division.width } : {}), shellPadding: 0, }; diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 9cc4f4c36d99b..7639e18852b92 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -98,6 +98,9 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; + readonly onComposerContentSizeChange?: ( + event: NativeSyntheticEvent<{ readonly height: number }>, + ) => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -295,6 +298,11 @@ export function ComposerEditor({ textPasteThresholdBytes={onPasteText ? PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES : 0} maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} style={{ flex: 1, minHeight: 0 }} + onComposerContentSizeChange={ + props.onContentHeightChange + ? (event) => props.onContentHeightChange?.(event.nativeEvent.height) + : undefined + } onComposerChange={(event) => { const acknowledgedEventCount = acceptNativeEvent( event.nativeEvent.eventCount, diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 5df13972af069..addc8561ebcea 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -19,6 +19,7 @@ export function ComposerEditor({ singleLineCentered: _singleLineCentered, enterBehavior: _enterBehavior, readOnly = false, + onContentHeightChange, ...props }: ComposerEditorProps) { const inputRef = useRef(null); @@ -45,6 +46,9 @@ export function ComposerEditor({ editable={(props.editable ?? true) && !readOnly} selection={selection} onSelectionChange={(event) => props.onSelectionChange?.(event.nativeEvent.selection)} + onContentSizeChange={(event) => + onContentHeightChange?.(event.nativeEvent.contentSize.height) + } multiline={props.multiline ?? true} placeholderTextColorClassName={"accent-placeholder"} className="text-foreground" diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index c6a9bfc11fa57..330548b45997c 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -65,6 +65,8 @@ export interface ComposerEditorProps { readonly onPasteText?: (paste: ComposerTextPaste) => void; readonly onFocus?: () => void; readonly onBlur?: () => void; + /** Height of the laid-out text in dp, for hosts that size the editor to its content. */ + readonly onContentHeightChange?: (height: number) => void; /** * Hardware-keyboard Return behavior on iOS. No-op on Android, which has no * hardware Return handling.