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