From 20e7e1fb130ec72a32b0b25b3133bfbd534dc11d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 05:09:58 -0700 Subject: [PATCH 1/5] feat(mobile): Android foldables use the sidebar header instead of a rail Split view on Android loses the 80dp navigation rail. The sidebar header now holds the Chats/PRs switch, search, filter, settings, and New thread, so no buttons float over the thread list. While the thread composer has the soft keyboard in split view, the sidebar steps aside, the header drops to one line, the composer stays one row (with the model picker and turn timer), and the Working pill stops taking feed height. Folded phones keep the bottom bar and FABs. Co-Authored-By: Claude Opus 5.5 --- .../T3WorkspaceNavigationModule.kt | 83 ++++------- .../src/components/AndroidScreenHeader.tsx | 22 ++- .../src/components/ScreenHeader.android.tsx | 1 + .../src/components/ScreenHeader.types.ts | 2 + .../components/useMaterialToolbarLayout.ts | 11 +- .../features/home/AndroidHomeFab.android.tsx | 6 +- .../features/home/AndroidHomeFab.shared.tsx | 1 - .../home/MaterialThreadListToolbar.tsx | 74 ++++++++-- .../home/WorkspaceConnectionTitle.tsx | 18 ++- .../layout/AdaptiveWorkspaceLayout.tsx | 131 +++++++++-------- .../MaterialPullRequestToolbar.android.tsx | 29 ++-- .../src/features/threads/ThreadComposer.tsx | 135 ++++++++++++------ .../features/threads/ThreadDetailScreen.tsx | 15 +- .../src/features/threads/ThreadHeader.tsx | 4 +- .../threads/ThreadNavigationSidebar.tsx | 10 +- .../AndroidWorkspaceNavigation.android.tsx | 56 ++------ .../workspace/AndroidWorkspaceNavigation.tsx | 7 +- .../AndroidWorkspaceSidebar.android.tsx | 2 +- .../workspace/AndroidWorkspaceTabSwitch.tsx | 61 ++++++++ ...ndroid-workspace-navigation-colors.test.ts | 8 +- .../android-workspace-navigation-colors.ts | 9 +- .../workspace/useAndroidWorkspaceSelection.ts | 18 +++ apps/mobile/src/lib/layout.test.ts | 10 ++ apps/mobile/src/lib/layout.ts | 11 +- .../src/native/T3ComposerEditor.native.tsx | 8 ++ apps/mobile/src/native/T3ComposerEditor.tsx | 4 + .../src/native/T3ComposerEditor.types.ts | 2 + 27 files changed, 471 insertions(+), 267 deletions(-) create mode 100644 apps/mobile/src/features/workspace/AndroidWorkspaceTabSwitch.tsx 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 e13c1fbbae501..f706deed522c9 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 Android 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/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 1ae2464e0a771..5e03f07abdee7 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -41,11 +41,14 @@ 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; }) { 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( @@ -74,9 +77,19 @@ export function AndroidScreenHeader(props: { {props.leading} - + {props.titleContent ?? ( - + {props.title} )} @@ -84,7 +97,10 @@ export function AndroidScreenHeader(props: { {props.subtitle} diff --git a/apps/mobile/src/components/ScreenHeader.android.tsx b/apps/mobile/src/components/ScreenHeader.android.tsx index 333afbce7cccb..ba3674133d3f0 100644 --- a/apps/mobile/src/components/ScreenHeader.android.tsx +++ b/apps/mobile/src/components/ScreenHeader.android.tsx @@ -158,6 +158,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { onBack={props.onBack} leading={props.sidebar !== false ? : undefined} hideBottomBorder={props.hideBottomBorder} + compact={props.compact} actions={ search ? [ diff --git a/apps/mobile/src/components/ScreenHeader.types.ts b/apps/mobile/src/components/ScreenHeader.types.ts index 6d308aff39798..56e3830aa10f1 100644 --- a/apps/mobile/src/components/ScreenHeader.types.ts +++ b/apps/mobile/src/components/ScreenHeader.types.ts @@ -69,6 +69,8 @@ export interface ScreenHeaderProps { readonly onPress?: () => void; }; readonly hideBottomBorder?: boolean; + /** Android: one title row with the subtitle inline. iOS ignores it. */ + readonly compact?: 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/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/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 0f074105d2c58..27d2eb898294e 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -14,6 +14,7 @@ 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 +23,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. + * The sidebar variant swaps the brand for the Chats/PRs switch and 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 +39,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 +113,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. */} @@ -140,15 +201,10 @@ export function MaterialThreadListToolbar(props: { {/* 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..bf12a8bc6a12f 100644 --- a/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx +++ b/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx @@ -6,10 +6,17 @@ 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. */ -export function MaterialPullRequestToolbar(props: { readonly onRequestVisibility?: () => void }) { +/** + * PR search shares the thread list's native Android header placement. The split-view + * sidebar shows the Chats/PRs switch in place of the brand. + */ +export function MaterialPullRequestToolbar(props: { + readonly onRequestVisibility?: () => void; + readonly sidebar?: boolean; +}) { const navigation = useNavigation(); const workspace = useWorkspaceTabs()!; const { dispatch, getScrollOffset, recordScrollOffset, isTabActive } = workspace; @@ -36,6 +43,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", () => { @@ -62,6 +74,12 @@ export function MaterialPullRequestToolbar(props: { readonly onRequestVisibility value={tab.query} onChangeText={(query) => dispatch({ type: "query", tab: "pullRequests", query })} /> + ) : props.sidebar ? ( + // Cancel the header's title inset so the switch matches the Chats header. + + + + ) : ( } - onPress={() => - navigation.navigate("SettingsSheet", { - screen: "SettingsContent", - params: { screen: "SettingsEnvironments" }, - }) - } + onPress={openEnvironments} /> ) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index a45f319423fe2..531df1ed0b27d 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,26 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer [navigation, settingsSheetPresentation.onStackTransitionsFinished], ); + // 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 a queued edit's attachments above its row. */} + {isExpanded && + !usesCard && + queuedEdit !== null && + queuedEdit.existingAttachments.length > 0 ? ( + + + + ) : 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..197019d9762b2 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -493,7 +493,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 +561,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. @@ -1482,6 +1490,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..d559ca7867636 100644 --- a/apps/mobile/src/features/threads/ThreadHeader.tsx +++ b/apps/mobile/src/features/threads/ThreadHeader.tsx @@ -19,7 +19,8 @@ export function ThreadHeader( }, ) { 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 +100,7 @@ export function ThreadHeader( onBack={layout.usesSplitView ? undefined : returnToList} actions={androidHeaderActions} hideBottomBorder + compact={composerKeyboardActive} /> {native.fallback} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 92cc958a8c90b..6c877b2739f9e 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 @@ -1016,7 +1013,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 +1044,7 @@ function ThreadNavigationSidebarPane( onOpenSettings={props.onOpenSettings} onOpenEnvironments={props.onOpenEnvironmentSettings} onRequestVisibility={props.onRequestVisibility} + onStartNewTask={props.onStartNewTask} /> ) : ( void; -}) { +/** Compact Android bottom bar. Split view switches tabs from the sidebar header instead. */ +export function AndroidWorkspaceNavigation() { const workspace = useWorkspaceTabs()!; - const navigation = useNavigation(); + const selectTab = useSelectAndroidWorkspaceTab(); 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, false); + if (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) }} + onTabSelected={(event) => selectTab(event.nativeEvent.tab)} + 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 a3da818b21c5f..cd5ef38dcf72f 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx @@ -1,9 +1,4 @@ /** Other clients own their workspace navigation chrome. */ -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 711f8cbae5aa9..c383b73e999c9 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/AndroidWorkspaceTabSwitch.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceTabSwitch.tsx new file mode 100644 index 0000000000000..82721179ba7e8 --- /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 replaces the brand in the Android split-view sidebar header. */ +export function AndroidWorkspaceTabSwitch() { + const workspace = useWorkspaceTabs(); + const selectTab = useSelectAndroidWorkspaceTab(); + const { themeVariables } = useAppearancePreferences(); + const { buttonSize, iconSize, scale } = useAndroidControlSizing(); + const colors = androidWorkspaceNavigationColors(themeVariables, true); + 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/android-workspace-navigation-colors.test.ts b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts index 2216ed6b4d631..4de8c9f83d3d5 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 @@ -41,8 +41,8 @@ describe("Android workspace navigation colors", () => { 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)); + for (const onHeader of [false, true]) { + expectVisibleSelection(androidWorkspaceNavigationColors(variables, onHeader)); } expect(variables).toEqual(snapshot); } @@ -91,8 +91,8 @@ describe("Android workspace navigation colors", () => { appearance, getMobileThemeRuntimeVariables("material-you", appearance, "android"), ); - for (const rail of [false, true]) { - expectVisibleSelection(androidWorkspaceNavigationColors(variables, rail)); + for (const onHeader of [false, true]) { + expectVisibleSelection(androidWorkspaceNavigationColors(variables, onHeader)); } } }, 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..b9b3d9d5d901c 100644 --- a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts +++ b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts @@ -5,10 +5,13 @@ import { type MobileThemeVariables, } from "../../lib/mobileTheme"; -/** Both navigation positions keep a visible tonal selection against their actual surface. */ -export function androidWorkspaceNavigationColors(colors: MobileThemeVariables, rail: boolean) { +/** + * Tab selection colors for the compact bottom bar and the split-view sidebar + * switch, which sits directly on the header surface. + */ +export function androidWorkspaceNavigationColors(colors: MobileThemeVariables, onHeader: boolean) { const header = flattenThemeColor(colors["--color-header"], colors["--color-screen"]); - const containerColor = rail + const containerColor = onHeader ? header : flattenThemeColor(themeColorWithAlpha(colors["--color-header-foreground"], 0.05), header); const indicatorColor = themeColorWithContrast( 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..fbf7810276139 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -18,6 +18,9 @@ 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; @@ -93,6 +96,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 +135,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. From ef7cecb5166f291ef39819ec74a8c05d39687a2d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 05:27:32 -0700 Subject: [PATCH 2/5] fix(mobile): one-row composer keeps removable attachments above the row Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/ThreadComposer.tsx | 109 ++++++++---------- 1 file changed, 50 insertions(+), 59 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 531df1ed0b27d..6c89d868a28ac 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -795,6 +795,52 @@ 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 = ( @@ -900,24 +946,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } } > - {/* The one-row composer keeps a queued edit's attachments above its row. */} - {isExpanded && - !usesCard && - queuedEdit !== null && - queuedEdit.existingAttachments.length > 0 ? ( - - - - ) : null} + {/* The one-row composer keeps removable attachments above its row. */} + {isExpanded && !usesCard ? editingAttachments : null} ) : null} - {usesCard && queuedEdit !== null && queuedEdit.existingAttachments.length > 0 ? ( - - - - ) : null} - {usesCard && 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} + {usesCard ? editingAttachments : null} - {!usesCard && stripAttachments.length > 0 ? ( + {!isExpanded && stripAttachments.length > 0 ? ( {stripAttachments.slice(0, 3).map((attachment) => ( Date: Thu, 15 Oct 2026 04:02:44 -0700 Subject: [PATCH 3/5] feat(mobile): flat Android surfaces, header tab switch everywhere, collapsing thread title - Content no longer sits in a rounded, inset sheet under the header. - The Chats/PRs switch replaces the brand on compact screens too, so the 80dp bottom navigation bar (and its native view) is gone. - The thread toolbar collapses while the feed is scrolled back into history and returns at the latest message. Co-Authored-By: Claude Opus 5.5 --- .../T3WorkspaceNavigationModule.kt | 149 -------------- .../src/main/res/drawable/t3_chats.xml | 3 - .../main/res/drawable/t3_pull_requests.xml | 3 - .../expo-module.config.json | 1 - .../src/components/AndroidScreenHeader.tsx | 186 ++++++++++-------- .../src/components/MaterialScreenContent.tsx | 8 +- .../src/components/ScreenHeader.android.tsx | 1 + .../src/components/ScreenHeader.types.ts | 2 + .../features/files/ThreadFilesRouteScreen.tsx | 4 +- .../files/thread-file-navigator-pane.tsx | 2 +- apps/mobile/src/features/home/HomeScreen.tsx | 14 +- .../home/MaterialThreadListToolbar.tsx | 11 +- .../MaterialPullRequestToolbar.android.tsx | 28 +-- .../pull-requests/PullRequestRouteScreen.tsx | 19 +- .../src/features/review/ReviewSheet.tsx | 2 +- .../features/threads/ThreadAgentsSheet.tsx | 6 +- .../features/threads/ThreadDetailScreen.tsx | 6 + .../src/features/threads/ThreadHeader.tsx | 3 + .../threads/ThreadNavigationSidebar.tsx | 4 - .../features/threads/ThreadQueueControl.tsx | 6 +- .../features/threads/ThreadRouteScreen.tsx | 10 +- .../features/threads/git/GitOverviewSheet.tsx | 6 +- .../AndroidWorkspaceNavigation.android.tsx | 58 ------ .../workspace/AndroidWorkspaceNavigation.tsx | 4 - .../AndroidWorkspaceSidebar.android.tsx | 4 +- .../workspace/AndroidWorkspaceTabSwitch.tsx | 4 +- .../workspace/WorkspaceRootScreen.android.tsx | 29 ++- ...ndroid-workspace-navigation-colors.test.ts | 33 ++-- .../android-workspace-navigation-colors.ts | 13 +- 29 files changed, 173 insertions(+), 446 deletions(-) delete mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt delete mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chats.xml delete mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_pull_requests.xml delete mode 100644 apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx delete mode 100644 apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx 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 f706deed522c9..0000000000000 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ /dev/null @@ -1,149 +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.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.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("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") - } - } -} - -/** Material3 bottom navigation for the compact Android workspace. */ -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 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") - 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 5e03f07abdee7..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"; @@ -43,6 +44,8 @@ export function AndroidScreenHeader(props: { 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"); @@ -57,107 +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.subtitle ? ( + + {props.subtitle} + + ) : null} + + + {visibleActions.map((action) => + action.loading ? ( + + + + ) : ( + + ), )} - > - {props.titleContent ?? ( - 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.title} - - )} - {props.subtitle ? ( - ( + )} - > - {props.subtitle} - + ) : 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 ba3674133d3f0..84f03fe36e3b6 100644 --- a/apps/mobile/src/components/ScreenHeader.android.tsx +++ b/apps/mobile/src/components/ScreenHeader.android.tsx @@ -159,6 +159,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { 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 56e3830aa10f1..f947908a455ad 100644 --- a/apps/mobile/src/components/ScreenHeader.types.ts +++ b/apps/mobile/src/components/ScreenHeader.types.ts @@ -71,6 +71,8 @@ export interface ScreenHeaderProps { 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/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/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index cd0e1c120fc00..fdbe743f2315e 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -936,11 +936,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( {v2ListEmpty} @@ -1041,11 +1037,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 diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 27d2eb898294e..ba99623bbbb7d 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -12,7 +12,6 @@ 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"; @@ -27,7 +26,7 @@ import { AndroidWorkspaceTabSwitch } from "../workspace/AndroidWorkspaceTabSwitc /** * One toolbar height for the compact list and expanded sidebar, including search. - * The sidebar variant swaps the brand for the Chats/PRs switch and keeps Filter and + * 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: { @@ -178,12 +177,12 @@ export function MaterialThreadListToolbar(props: { ) : ( <> - {/* Match the visible inset of the trailing 48dp icon button. */} - + + } /> void; - readonly sidebar?: boolean; -}) { +/** 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()!; const { dispatch, getScrollOffset, recordScrollOffset, isTabActive } = workspace; @@ -74,25 +67,12 @@ export function MaterialPullRequestToolbar(props: { value={tab.query} onChangeText={(query) => dispatch({ type: "query", tab: "pullRequests", query })} /> - ) : props.sidebar ? ( - // Cancel the header's title inset so the switch matches the Chats header. + ) : ( + // Cancel the header's title inset so the switch lines up with the Chats header. - ) : ( - - - } - onPress={openEnvironments} - /> - ) } leading={ 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/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 197019d9762b2..3a7d1a61d19a1 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -218,6 +218,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; @@ -1099,6 +1101,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"; diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx index d559ca7867636..1951032deae9d 100644 --- a/apps/mobile/src/features/threads/ThreadHeader.tsx +++ b/apps/mobile/src/features/threads/ThreadHeader.tsx @@ -16,6 +16,8 @@ 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"); @@ -101,6 +103,7 @@ export function ThreadHeader( 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 6c877b2739f9e..cfb0377946544 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -983,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 } } diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index e8d81a20a8bc5..3906fce09cdf9 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -27,7 +27,6 @@ import { assetImageSource } from "../../lib/assetImageSource"; import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit"; import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL, buildCancelQueuedRunCommand, @@ -54,7 +53,6 @@ export function useThreadQueuedCount(target: QueueTarget) { } export function ThreadQueueSheet({ route }: StaticScreenProps) { - 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 e8f45d99d4b79..0000000000000 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { requireNativeView } from "expo"; -import { 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 { useSelectAndroidWorkspaceTab } from "./useAndroidWorkspaceSelection"; -import { useWorkspaceTabs } from "./WorkspaceTabsContext"; -import type { WorkspaceTab } from "./workspace-tabs-state"; - -interface NativeNavigationProps extends ViewProps { - 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; -} - -/** Compact Android bottom bar. Split view switches tabs from the sidebar header instead. */ -export function AndroidWorkspaceNavigation() { - const workspace = useWorkspaceTabs()!; - const selectTab = useSelectAndroidWorkspaceTab(); - const insets = useSafeAreaInsets(); - const keyboardVisible = useKeyboardState((state) => state.isVisible); - const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); - const { scale } = useAndroidControlSizing(); - const { containerColor, indicatorColor, selectedColor, unselectedColor } = - androidWorkspaceNavigationColors(colors, false); - if (keyboardVisible) return null; - - return ( - - selectTab(event.nativeEvent.tab)} - 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 deleted file mode 100644 index cd5ef38dcf72f..0000000000000 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx +++ /dev/null @@ -1,4 +0,0 @@ -/** Other clients own their workspace navigation chrome. */ -export function AndroidWorkspaceNavigation() { - return null; -} diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx index c383b73e999c9..620f7d8d4abf3 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx @@ -17,8 +17,8 @@ export function AndroidWorkspaceSidebar(props: { ) : ( - - + + diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceTabSwitch.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceTabSwitch.tsx index 82721179ba7e8..1241c66579b5b 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceTabSwitch.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceTabSwitch.tsx @@ -13,13 +13,13 @@ const TABS = [ { tab: "pullRequests", label: "Pull requests", icon: "arrow.triangle.pull" }, ] as const satisfies ReadonlyArray<{ tab: WorkspaceTab; label: string; icon: string }>; -/** Chats/PRs switch that replaces the brand in the Android split-view sidebar header. */ +/** 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, true); + const colors = androidWorkspaceNavigationColors(themeVariables); if (workspace === null) return null; const segmentHeight = Math.round(32 * Math.max(1, scale)); diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx index 3e6f31cb59024..b718655af5058 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx @@ -1,6 +1,5 @@ import { useMemo } from "react"; import { View } from "react-native"; -import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { EmptyState } from "../../components/EmptyState"; @@ -10,13 +9,11 @@ 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 context = useMemo( () => ({ @@ -49,21 +46,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 4de8c9f83d3d5..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 onHeader of [false, true]) { - expectVisibleSelection(androidWorkspaceNavigationColors(variables, onHeader)); - } - 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 onHeader of [false, true]) { - expectVisibleSelection(androidWorkspaceNavigationColors(variables, onHeader)); - } + 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 b9b3d9d5d901c..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,19 +1,12 @@ import { flattenThemeColor, - themeColorWithAlpha, themeColorWithContrast, type MobileThemeVariables, } from "../../lib/mobileTheme"; -/** - * Tab selection colors for the compact bottom bar and the split-view sidebar - * switch, which sits directly on the header surface. - */ -export function androidWorkspaceNavigationColors(colors: MobileThemeVariables, onHeader: boolean) { - const header = flattenThemeColor(colors["--color-header"], colors["--color-screen"]); - const containerColor = onHeader - ? 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, From 9d6fa0157f8e58c56b9da8e04627aa375267405d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 04:05:44 -0700 Subject: [PATCH 4/5] fix(mobile): compact chat list clears the FABs above the bottom inset Co-Authored-By: Claude Opus 5.5 --- .../features/workspace/WorkspaceRootScreen.android.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx index b718655af5058..d1fc6a3be2bf0 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { EmptyState } from "../../components/EmptyState"; @@ -15,12 +16,15 @@ export function WorkspaceRootScreen() { const { layout } = useAdaptiveWorkspaceLayout(); const workspace = useWorkspaceTabs()!; 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. From 75baac4ff723b5a324d67806b3ad6e1c995501ab Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 15 Oct 2026 04:10:32 -0700 Subject: [PATCH 5/5] feat(mobile): Android caps the feed, composer, and full-width thread list at a reading width Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/home/HomeScreen.tsx | 22 +++++++++++++++++-- .../features/threads/ThreadDetailScreen.tsx | 13 +++++++++-- apps/mobile/src/lib/layout.ts | 3 +++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index fdbe743f2315e..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, @@ -1086,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/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3a7d1a61d19a1..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"; @@ -853,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, ); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index fbf7810276139..060a3db223c59 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -24,6 +24,9 @@ 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;