diff --git a/apps/mobile/assets/icons/file-diff.png b/apps/mobile/assets/icons/file-diff.png
new file mode 100644
index 0000000000000000000000000000000000000000..dbd2123bd4c22fdb136ab4e8ca74e5fe2edeece5
Binary files /dev/null and b/apps/mobile/assets/icons/file-diff.png differ
diff --git a/apps/mobile/assets/icons/file-diff@2x.png b/apps/mobile/assets/icons/file-diff@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..b94261ee69c5668099a382ab6b4484ee512ffc34
Binary files /dev/null and b/apps/mobile/assets/icons/file-diff@2x.png differ
diff --git a/apps/mobile/assets/icons/file-diff@3x.png b/apps/mobile/assets/icons/file-diff@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..38d4c42059faa1e68c9cb88b09e3cde32d0cfc13
Binary files /dev/null and b/apps/mobile/assets/icons/file-diff@3x.png differ
diff --git a/apps/mobile/modules/t3-native-controls/android/build.gradle b/apps/mobile/modules/t3-native-controls/android/build.gradle
index ba0622b7f0ef6f08d1170e36fa1e81ab04f69557..e4e2e317cacf7aaa0055fb77d2fe74b0d699699e 100644
--- a/apps/mobile/modules/t3-native-controls/android/build.gradle
+++ b/apps/mobile/modules/t3-native-controls/android/build.gradle
@@ -1,5 +1,13 @@
+buildscript {
+ repositories { mavenCentral() }
+ dependencies {
+ classpath("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:${kotlinVersion}")
+ }
+}
+
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'
+apply plugin: 'org.jetbrains.kotlin.plugin.compose'
group = 'com.t3tools.nativecontrols'
version = '0.0.0'
@@ -12,8 +20,11 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
}
+ buildFeatures { compose true }
}
dependencies {
implementation project(':expo-modules-core')
+ implementation 'androidx.compose.ui:ui-android:1.10.6'
+ implementation 'androidx.compose.material3:material3-android:1.5.0-alpha17'
}
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
new file mode 100644
index 0000000000000000000000000000000000000000..e13c1fbbae5011b9fa8b64778651bb49559f3435
--- /dev/null
+++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt
@@ -0,0 +1,184 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..20321f29bb9da9bc96c1a7972de52a16332c1e3f
--- /dev/null
+++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chats.xml
@@ -0,0 +1,3 @@
+
+
+
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
new file mode 100644
index 0000000000000000000000000000000000000000..98c043c9d7fda7097ae4c0ac96b686a6c617dbc7
--- /dev/null
+++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_pull_requests.xml
@@ -0,0 +1,3 @@
+
+
+
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 0c37ea9a24ad100422eba4838c1cb873854aa601..17ddda2f766c6bf92a32f61a11cfd517fa8e3e4c 100644
--- a/apps/mobile/modules/t3-native-controls/expo-module.config.json
+++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json
@@ -7,6 +7,7 @@
"android": {
"modules": [
"expo.modules.t3nativecontrols.T3NativeControlsModule",
+ "expo.modules.t3nativecontrols.T3WorkspaceNavigationModule",
"expo.modules.t3nativecontrols.T3KeyboardCommandsModule"
]
}
diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt
index cee0c80146f0a20faa372544bfdd834c8dc630c1..58422149016c7e6a29833b971dcafefce8c96644 100644
--- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt
+++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt
@@ -41,6 +41,9 @@ class T3ReviewDiffModule : Module() {
Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double ->
view.setContentWidth(contentWidth.toFloat())
}
+ Prop("contentInsetBottom") { view: T3ReviewDiffView, value: Double ->
+ view.setContentInsetBottom(value.toFloat())
+ }
Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean ->
view.setScrollEnabled(enabled)
}
diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
index 62df467588682c413557f699cd4bdaee75505215..495a63719a74c1d8dc4d28517e85f14a6c9d4a92 100644
--- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
+++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
@@ -143,6 +143,12 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
canvasView.contentWidthPx = max(width, dp(value).toInt())
}
+ fun setContentInsetBottom(value: Float) {
+ canvasView.setPadding(0, 0, 0, max(0, dp(value).toInt()))
+ canvasView.setVerticalOffset(canvasView.verticalOffset())
+ canvasView.invalidate()
+ }
+
fun setInitialRowIndex(value: Double) {
initialRowIndex = value.toInt().coerceAtLeast(0)
pendingInitialScroll = true
@@ -818,7 +824,7 @@ private class DiffCanvasView(context: Context) : View(context) {
fun rowTop(index: Int): Int = rowOffsets[index.coerceIn(0, max(0, rowOffsets.size - 2))]
fun setVerticalOffset(value: Int) {
- val maxOffset = max(0, (rowOffsets.lastOrNull() ?: 0) - height)
+ val maxOffset = maxVerticalOffset()
val nextOffset = value.coerceIn(0, maxOffset)
if (verticalOffset == nextOffset) return
verticalOffset = nextOffset
@@ -832,7 +838,8 @@ private class DiffCanvasView(context: Context) : View(context) {
fun verticalOffset(): Int = verticalOffset
- fun maxVerticalOffset(): Int = max(0, (rowOffsets.lastOrNull() ?: 0) - height)
+ fun maxVerticalOffset(): Int =
+ max(0, (rowOffsets.lastOrNull() ?: 0) + paddingBottom - height)
fun setHorizontalOffset(value: Int) {
val nextOffset = value.coerceIn(0, maxHorizontalOffset())
@@ -932,8 +939,9 @@ private class DiffCanvasView(context: Context) : View(context) {
return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) }
}
}
- val index = rowIndexAt(verticalOffset + y.toInt())
- return rows.getOrNull(index)?.let {
+ val contentY = verticalOffset + y.toInt()
+ val index = rowIndexAt(contentY)
+ return rows.getOrNull(index)?.takeIf { contentY in 0 until rowOffsets.last() }?.let {
RowHit(
row = it,
top = rowOffsets[index] - verticalOffset,
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index ee5b314d369193bef06d7e78284677d54d0ae5b1..ea46df3235f5835b3c9a9f5e5b9cbc9870e00f11 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -31,6 +31,7 @@ import { useResolveClassNames } from "uniwind";
import { AppText as Text } from "./components/AppText";
import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen";
+import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider";
import {
PullRequestRouteScreen,
PullRequestFileRouteScreen,
@@ -170,6 +171,12 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined,
};
+// Android PR screens render their own Material toolbar.
+const PULL_REQUEST_HEADER_OPTIONS: AppScreenOptions = {
+ ...GLASS_HEADER_OPTIONS,
+ headerShown: Platform.OS !== "android",
+};
+
// SOLID: opaque sheet-colored header for surfaces that manage their own viewport.
const SOLID_HEADER_OPTIONS: AppScreenOptions = {
headerBackButtonDisplayMode: "minimal",
@@ -647,13 +654,15 @@ function RootStackLayout(props: {
-
- {props.children}
-
-
+
+
+ {props.children}
+
+
+
);
@@ -721,15 +730,15 @@ const RootStackConfig = createWorkspaceStackNavigator({
path: "pull-requests/:environmentId/:projectId/:repository/:number",
parse: { number: Number },
},
- options: GLASS_HEADER_OPTIONS,
+ options: PULL_REQUEST_HEADER_OPTIONS,
}),
PullRequestGuideStop: createNativeStackScreen({
screen: PullRequestGuideStopScreen,
- options: GLASS_HEADER_OPTIONS,
+ options: PULL_REQUEST_HEADER_OPTIONS,
}),
PullRequestFile: createNativeStackScreen({
screen: PullRequestFileRouteScreen,
- options: GLASS_HEADER_OPTIONS,
+ options: PULL_REQUEST_HEADER_OPTIONS,
}),
PullRequestFiles: createNativeStackScreen({
screen: PullRequestFilesRouteScreen,
@@ -743,7 +752,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
PullRequestReviewers: createNativeStackScreen({
screen: PullRequestReviewersScreen,
options: {
- ...GLASS_HEADER_OPTIONS,
+ ...PULL_REQUEST_HEADER_OPTIONS,
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.75, 0.92],
sheetGrabberVisible: true,
@@ -752,7 +761,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
}),
PullRequestDiscussion: createNativeStackScreen({
screen: PullRequestDiscussionScreen,
- options: GLASS_HEADER_OPTIONS,
+ options: PULL_REQUEST_HEADER_OPTIONS,
}),
PullRequestInlineComment: createNativeStackScreen({
screen: PullRequestInlineCommentScreen,
diff --git a/apps/mobile/src/components/AppSymbol.ios.tsx b/apps/mobile/src/components/AppSymbol.ios.tsx
index cf4f60e63bc1f6670e6d87b7a8751ebb092e8721..47b7e5be75bc3b09af4ce7796fadf8b4ea88f11b 100644
--- a/apps/mobile/src/components/AppSymbol.ios.tsx
+++ b/apps/mobile/src/components/AppSymbol.ios.tsx
@@ -5,6 +5,7 @@ import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequ
import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft";
import type { SFSymbol } from "expo-symbols";
import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest";
+import IconFileDiff from "@tabler/icons-react-native/IconFileDiff";
import { SymbolView as ExpoSymbolView } from "expo-symbols";
import { withUniwind } from "uniwind";
import type { AppSymbolViewProps } from "./AppSymbol";
@@ -12,7 +13,8 @@ import type { AppSymbolViewProps } from "./AppSymbol";
export type { SFSymbol } from "expo-symbols";
export type { AppSymbolName } from "./AppSymbol";
-const GIT_ICONS = {
+const CUSTOM_ICONS = {
+ "file.diff": IconFileDiff,
"arrow.triangle.pull": IconGitPullRequest,
"arrow.triangle.merge": IconGitMerge,
"arrow.triangle.branch": IconGitBranch,
@@ -24,10 +26,10 @@ const GIT_ICONS = {
function AppSymbolView(props: AppSymbolViewProps) {
const name = typeof props.name === "string" ? props.name : props.name.ios;
- const GitIcon = GIT_ICONS[name as keyof typeof GIT_ICONS];
- if (GitIcon) {
+ const CustomIcon = CUSTOM_ICONS[name as keyof typeof CUSTOM_ICONS];
+ if (CustomIcon) {
return (
-
+ Record<
+ SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff",
+ Icon
+ >
>;
const SF_ICON_LOOKUP: Partial<
- Record
+ Record<
+ SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff",
+ Icon
+ >
> = ANDROID_ICON_BY_SF_SYMBOL;
// Callers can pass `{ ios, android }` names where `android` is a Material
diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx
index 7425e710a1711e99bbf7248c9f4e607256aa72ec..0c15bcc09ce7b717d3eae8a67bed1acfe6c6f011 100644
--- a/apps/mobile/src/features/home/HomeHeader.android.tsx
+++ b/apps/mobile/src/features/home/HomeHeader.android.tsx
@@ -92,8 +92,6 @@ export function HomeHeader(props: HomeHeaderProps) {
[props],
);
- if (props.usesWorkspaceToolbar) return null;
-
return (
<>
diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx
index 592cb162b9309339857abe17054383b8abeb3b7f..7b75e4ae69b21213b451d1fee9fdbe4ff2e933b4 100644
--- a/apps/mobile/src/features/home/HomeRouteScreen.tsx
+++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx
@@ -209,7 +209,7 @@ export function HomeRouteScreen() {
return (
navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
>
<>
@@ -230,7 +230,7 @@ export function HomeRouteScreen() {
params: { screen: "SettingsEnvironments" },
}),
}),
- headerShown: true,
+ headerShown: Platform.OS !== "android",
}}
/>
)}
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index bf63239583ab6964c4cbd0b5ce552ee0d0170501..cd0e1c120fc00b4b7664edbca39b729bc4b9e9dd 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -983,7 +983,7 @@ export function HomeScreen(props: HomeScreenProps) {
const v2ListHeader = (
<>
{listHeader}
- {workspace ? (
+ {Platform.OS === "ios" && workspace ? (
({ key: scope.key, label: scope.title }))}
diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx
index 3399099d23833e991780b10409b0831cb4a2ac99..0f074105d2c58553cbd8153c0c687ad833dbed16 100644
--- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx
+++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react";
+import { useNavigation, useNavigationState } from "@react-navigation/native";
+import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
import {
BackHandler,
Keyboard,
@@ -35,6 +37,12 @@ export function MaterialThreadListToolbar(props: {
readonly onRequestVisibility?: () => void;
}) {
const insets = useSafeAreaInsets();
+ const navigation = useNavigation();
+ const workspace = useWorkspaceTabs();
+ const isTabActive = workspace?.isTabActive;
+ const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home");
+ const listFocused =
+ homeFocused && (workspace === null || workspace.state.selectedTab === "threads");
const { fabSize } = useAndroidControlSizing();
const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout();
const { state } = useWorkspaceState();
@@ -57,13 +65,22 @@ export function MaterialThreadListToolbar(props: {
}, [onSearchQueryChange]);
useEffect(() => {
- if (!searching) return;
+ if (!searching || !listFocused) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
- closeSearch();
+ // Frozen Home screens can keep effects alive after a detail route opens.
+ const state = navigation.getState();
+ if (
+ !state ||
+ state.routes[state.index]?.name !== "Home" ||
+ isTabActive?.("threads") === false
+ )
+ return false;
+ if (Keyboard.isVisible()) Keyboard.dismiss();
+ else closeSearch();
return true;
});
return () => subscription.remove();
- }, [closeSearch, searching]);
+ }, [closeSearch, searching, listFocused, navigation, isTabActive]);
const filterIcon = props.filterCustomized
? "line.3.horizontal.decrease.circle.fill"
diff --git a/apps/mobile/src/features/home/home-thread-navigation.test.ts b/apps/mobile/src/features/home/home-thread-navigation.test.ts
index 30ef1e7a711bbc4953a5bb87a10de5d136a52ce6..3f2b4a4fee4ff101c78214d4ad7238bceecd107a 100644
--- a/apps/mobile/src/features/home/home-thread-navigation.test.ts
+++ b/apps/mobile/src/features/home/home-thread-navigation.test.ts
@@ -19,6 +19,8 @@ function loadRouters() {
};
}
+vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Keyboard: { dismiss: vi.fn() } }));
+
vi.mock("@react-navigation/native", () => {
const { CommonActions, StackActions } = loadRouters();
return { CommonActions, StackActions };
diff --git a/apps/mobile/src/features/home/home-thread-navigation.ts b/apps/mobile/src/features/home/home-thread-navigation.ts
index f26e624ac3a8228d22a69ebfd87b55d20edf3fc7..317b6805915715357a7511a8bf40879d6523e029 100644
--- a/apps/mobile/src/features/home/home-thread-navigation.ts
+++ b/apps/mobile/src/features/home/home-thread-navigation.ts
@@ -7,6 +7,8 @@ import {
} from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useCallback, useEffect, useRef } from "react";
+import { Platform } from "react-native";
+import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
type ThreadSelection = Pick;
@@ -34,6 +36,7 @@ export function useHomeThreadSelection() {
const navigation =
useNavigation>();
const dismissingRouteKey = useRef(null);
+ const selectAndroidWorkspace = useAndroidWorkspaceSelection();
useEffect(() => {
const clear = () => {
@@ -58,6 +61,13 @@ export function useHomeThreadSelection() {
return useCallback(
(thread: ThreadSelection) => {
+ if (Platform.OS === "android") {
+ selectAndroidWorkspace({
+ name: "Thread",
+ params: { environmentId: String(thread.environmentId), threadId: String(thread.id) },
+ });
+ return;
+ }
navigation.dispatch((state) =>
createHomeThreadNavigationAction({
state,
@@ -66,6 +76,6 @@ export function useHomeThreadSelection() {
}),
);
},
- [navigation],
+ [navigation, selectAndroidWorkspace],
);
}
diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
index 5fbe3257c1c3c776a8acb8d84803877d14a060d3..c0572321e892a5a76d4ceebbebb515f517b3b39b 100644
--- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
+++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
@@ -24,6 +24,7 @@ import {
} from "react";
import { Platform, useWindowDimensions, View } from "react-native";
import Animated, {
+ runOnJS,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
@@ -55,6 +56,13 @@ import {
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";
import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
@@ -243,7 +251,8 @@ function AdaptiveWorkspaceLayoutContent(
const nativeWorkspace = use(NativeWorkspaceModeContext);
const windowDimensions = useWindowDimensions();
const nativeMetrics = useNativeLayoutMetrics();
- const { width, height } = nativeMetrics ?? windowDimensions;
+ const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions;
+ const { scale: androidControlScale } = useAndroidControlSizing();
const pathname = props.pathname;
const navigation = useNavigation();
const activeRoleOwner = useRef(null);
@@ -261,10 +270,18 @@ function AdaptiveWorkspaceLayoutContent(
const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] =
useState(null);
const baseLayout = useMemo(
- () => deriveLayout({ width, height, nativeMetrics }),
- [height, width, nativeMetrics],
+ () => deriveLayout({ width: viewportWidth, height, nativeMetrics }),
+ [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;
// 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).
@@ -478,22 +495,80 @@ function AdaptiveWorkspaceLayoutContent(
[navigation],
);
+ const inspectorColumnTargetWidth =
+ workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible
+ ? (panes.auxiliaryPaneWidth ?? 0)
+ : 0;
+ const contentTargetWidth = layout.usesSplitView
+ ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth)
+ : null;
+ const [contentSizing, setContentSizing] = useState({
+ viewportWidth,
+ target: contentTargetWidth,
+ sidebarVisible: panes.primarySidebarVisible,
+ width: contentTargetWidth,
+ revision: 0,
+ });
+ if (
+ Platform.OS === "android" &&
+ (contentSizing.viewportWidth !== viewportWidth ||
+ contentSizing.target !== contentTargetWidth ||
+ contentSizing.sidebarVisible !== panes.primarySidebarVisible)
+ ) {
+ const openingSidebar =
+ Platform.OS === "android" &&
+ contentSizing.viewportWidth === viewportWidth &&
+ !contentSizing.sidebarVisible &&
+ panes.primarySidebarVisible &&
+ contentSizing.width !== null &&
+ contentTargetWidth !== null;
+ setContentSizing({
+ viewportWidth,
+ target: contentTargetWidth,
+ sidebarVisible: panes.primarySidebarVisible,
+ // Clip the wide navigator during expansion, then reflow once at the end.
+ // Narrowing it first exposes an empty strip before the sidebar arrives.
+ width: openingSidebar
+ ? Math.max(contentSizing.width ?? 0, contentTargetWidth ?? 0)
+ : contentTargetWidth,
+ revision: contentSizing.revision + 1,
+ });
+ }
+ const settleContentWidth = useCallback((revision: number) => {
+ setContentSizing((current) =>
+ current.revision !== revision || current.width === current.target
+ ? current
+ : { ...current, width: current.target },
+ );
+ }, []);
+ const contentSettledWidth = Platform.OS === "android" ? contentSizing.width : contentTargetWidth;
+ const contentRevision = contentSizing.revision;
+
const renderedSidebarWidth = useSharedValue(
panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0,
);
+ const renderedRailWidth = useSharedValue(navigationRailWidth);
useEffect(() => {
if (nativeWorkspace) return;
const targetWidth = panes.primarySidebarVisible
? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0)
: 0;
- renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
+ 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.
@@ -507,24 +582,30 @@ function AdaptiveWorkspaceLayoutContent(
// animating columns merely clip/reveal it over a matching background.
// Continuously re-wrapping the chat feed was the main source of dropped
// frames during sidebar/inspector transitions.
- const inspectorColumnTargetWidth =
- workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible
- ? (panes.auxiliaryPaneWidth ?? 0)
- : 0;
- const contentSettledWidth = layout.usesSplitView
- ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth)
- : null;
const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth);
const renderedContentWidth = useDerivedValue(() =>
- Math.max(0, width - renderedSidebarWidth.value - renderedInspectorWidth.value),
+ Math.max(
+ 0,
+ viewportWidth -
+ renderedRailWidth.value -
+ renderedSidebarWidth.value -
+ renderedInspectorWidth.value,
+ ),
);
+ const selectAndroidWorkspace = useAndroidWorkspaceSelection();
const handleSelectThread = useCallback(
(thread: EnvironmentThreadShell) => {
const params = {
environmentId: String(thread.environmentId),
threadId: String(thread.id),
};
+ if (Platform.OS === "android") {
+ // Selecting a sidebar row enters its base chat, including from that chat's nested screens.
+ setFileInspectorPreferredVisible(false);
+ selectAndroidWorkspace({ name: "Thread", params });
+ return;
+ }
const navigationAction = resolveThreadSelectionNavigationAction({
usesSplitView: layout.usesSplitView,
pathname,
@@ -556,7 +637,14 @@ function AdaptiveWorkspaceLayoutContent(
}
navigation.navigate("Thread", params);
},
- [layout.usesSplitView, pathname, navigation, selectedThreadKey, props.workspaceRouteKey],
+ [
+ layout.usesSplitView,
+ pathname,
+ navigation,
+ selectedThreadKey,
+ props.workspaceRouteKey,
+ selectAndroidWorkspace,
+ ],
);
const contextValue = useMemo(
@@ -606,7 +694,21 @@ function AdaptiveWorkspaceLayoutContent(
return (
-
+
+ {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/layout/workspace-sidebar-toolbar.tsx b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx
index c5ef71422f4c661654f6f703794c3cd38f04178e..a88add3103157db1693b1724ff260dbb5cbffe3d 100644
--- a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx
+++ b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx
@@ -10,7 +10,7 @@ export function AndroidWorkspaceSidebarButton() {
return (
diff --git a/apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx b/apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..423275a39157db35077507437368df011d1fd274
--- /dev/null
+++ b/apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx
@@ -0,0 +1,26 @@
+import { HorizontalFloatingToolbar, Host, RNHostView } from "@expo/ui/jetpack-compose";
+import type { ReactElement } from "react";
+
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
+
+/** Native toolbar surface; hosted RN controls retain their existing menus and accessibility. */
+export function MaterialFloatingToolbar(props: { readonly children: ReactElement }) {
+ const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
+ return (
+
+
+ {props.children}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.tsx b/apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a64e3d2b39db515f42f56b8d31f2f064a561a83a
--- /dev/null
+++ b/apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.tsx
@@ -0,0 +1,5 @@
+import type { ReactElement } from "react";
+
+export function MaterialFloatingToolbar(props: { readonly children: ReactElement }) {
+ return props.children;
+}
diff --git a/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx b/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b7a3de14bde416dc6d534330eaff7fade30c7db8
--- /dev/null
+++ b/apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx
@@ -0,0 +1,116 @@
+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 { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
+
+/** PR search shares the thread list's native Android header placement. */
+export function MaterialPullRequestToolbar(props: { readonly onRequestVisibility?: () => void }) {
+ const navigation = useNavigation();
+ const workspace = useWorkspaceTabs()!;
+ const { dispatch, getScrollOffset, recordScrollOffset, isTabActive } = workspace;
+ const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home");
+ const listFocused = homeFocused && workspace.state.selectedTab === "pullRequests";
+ const { onRequestVisibility } = props;
+ const tab = workspace.state.tabs.pullRequests;
+ const input = useRef(null);
+ const searching = tab.searchMode !== "resting";
+ const openSearch = useCallback(() => {
+ onRequestVisibility?.();
+ dispatch({
+ type: "scroll",
+ tab: "pullRequests",
+ offset: getScrollOffset("pullRequests"),
+ });
+ dispatch({ type: "search", tab: "pullRequests" });
+ input.current?.focus();
+ return true;
+ }, [dispatch, getScrollOffset, onRequestVisibility]);
+ useHardwareKeyboardCommand("focusSearch", openSearch);
+ const closeSearch = useCallback(() => {
+ Keyboard.dismiss();
+ recordScrollOffset("pullRequests", tab.searchEntryOffset);
+ dispatch({ type: "cancel", tab: "pullRequests" });
+ }, [dispatch, recordScrollOffset, tab.searchEntryOffset]);
+ useEffect(() => {
+ if (!searching || !listFocused) return;
+ const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
+ // Frozen Home screens can keep effects alive after a detail route opens.
+ const state = navigation.getState();
+ if (!state || state.routes[state.index]?.name !== "Home" || !isTabActive("pullRequests"))
+ return false;
+ if (Keyboard.isVisible()) Keyboard.dismiss();
+ else closeSearch();
+ return true;
+ });
+ return () => subscription.remove();
+ }, [searching, closeSearch, listFocused, navigation, isTabActive]);
+ return (
+ dispatch({ type: "query", tab: "pullRequests", query })}
+ />
+ ) : (
+
+
+ }
+ onPress={() =>
+ navigation.navigate("SettingsSheet", {
+ screen: "SettingsContent",
+ params: { screen: "SettingsEnvironments" },
+ })
+ }
+ />
+
+ )
+ }
+ leading={
+ searching ? (
+
+ ) : undefined
+ }
+ actions={
+ searching
+ ? []
+ : [
+ {
+ accessibilityLabel: "Search pull requests",
+ icon: "magnifyingglass",
+ onPress: openSearch,
+ },
+ {
+ accessibilityLabel: "Open settings",
+ icon: "gearshape",
+ onPress: () =>
+ navigation.navigate("SettingsSheet", {
+ screen: "SettingsContent",
+ params: { screen: "Settings" },
+ }),
+ },
+ ]
+ }
+ />
+ );
+}
diff --git a/apps/mobile/src/features/pull-requests/PullRequestControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestControls.tsx
index 156b36b2155311c3561f5aecfa8e3f550a4d5345..cd9b84a2361f25b856647ac4e8e18edefdc6f9a9 100644
--- a/apps/mobile/src/features/pull-requests/PullRequestControls.tsx
+++ b/apps/mobile/src/features/pull-requests/PullRequestControls.tsx
@@ -9,6 +9,7 @@ import { AppText as Text } from "../../components/AppText";
import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
import { MaterialButton } from "../../components/MaterialButton";
import { MaterialIconButton } from "../../components/MaterialIconButton";
+import { MaterialFloatingToolbar } from "./MaterialFloatingToolbar";
import { ControlPillMenu } from "../../components/ControlPillMenu";
import { NativeHeaderToolbar } from "../../native/StackHeader";
import {
@@ -110,6 +111,7 @@ export function PullRequestActionToolbar(props: {
readonly params: PullRequestParams;
readonly filePath?: string;
readonly onOpenFiles?: () => void;
+ readonly onHeightChange?: (height: number) => void;
}) {
const navigation = useNavigation();
const { draft } = usePullRequestReviewDraft(props.params);
@@ -156,52 +158,57 @@ export function PullRequestActionToolbar(props: {
const primary = (
);
return (
props.onHeightChange?.(event.nativeEvent.layout.height)}
>
- {showAgentControls ? (
- agent.menuAction(action.task))}
- onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)}
- >
-
-
-
-
- ) : null}
- {showPrimary ? (
-
- {!pendingReview && chooseThread ? (
+
+
+ {showAgentControls ? (
agent.menuAction(action.task))}
onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)}
>
- {primary}
+
+
+
- ) : (
- primary
- )}
+ ) : null}
+ {showPrimary ? (
+
+ {!pendingReview && chooseThread ? (
+ agent.select(nativeEvent.event)}
+ >
+ {primary}
+
+ ) : (
+ primary
+ )}
+
+ ) : null}
+
- ) : null}
-
+
);
}
@@ -299,12 +306,13 @@ export function PullRequestActionToolbar(props: {
)}
) : null;
+ // Tabler file-diff geometry, rasterized at 1x/2x/3x for UIKit template images.
const filesControl = (
- confirm("merge")}
- className="min-h-11 min-w-0 flex-1 flex-row items-center justify-center gap-2 px-3 py-2 active:opacity-70"
- >
- {busy || checkingStack ? (
-
- ) : (
-
+ confirm("merge")}
/>
- )}
-
+ ) : (
+ confirm("merge")}
+ className="min-h-11 min-w-0 flex-1 flex-row items-center justify-center gap-2 px-3 py-2 active:opacity-70"
>
- {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label}
-
-
+ {busy || checkingStack ? (
+
+ ) : (
+
+ )}
+
+ {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label}
+
+
+ )}
{methods.length > 0 || canAutoMerge ? (
-
-
-
+ {Platform.OS === "android" ? (
+
+
+
+ ) : (
+
+
+
+ )}
) : null}
diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx
index 25d6788a5f7b2b1d2dbc907a8ae7c17f93efdfe5..c41d582e7e01c40b17063be3583f38264b6a8cf0 100644
--- a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx
+++ b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx
@@ -4,7 +4,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"
import type { PullRequestReviewVerdict } from "@t3tools/contracts";
import { useCallback, useRef, useState } from "react";
import { useFocusEffect } from "@react-navigation/native";
-import { Pressable, View } from "react-native";
+import { Platform, Pressable, View } from "react-native";
import { clearSubmittedReview } from "./pullRequestReviewDrafts";
import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft";
import { reviewPositionLabel } from "./pullRequestDiffComments";
@@ -137,7 +137,7 @@ export function PullRequestReviewSheet({
: "Post comment"
}
fullWidth
- tone="contrast"
+ tone={Platform.OS === "android" ? "primary" : "contrast"}
loading={submission.busy}
disabled={!canSubmit || result.isPending || result.error !== null}
onPress={() => void perform()}
diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx
index cca2435e5b2b9a23661c098f3197fd9c8c65295b..6e3328d915f8d09db3de556c331f83cccf79c0a5 100644
--- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx
+++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx
@@ -57,6 +57,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
+import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
+import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
+import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar";
+import { useAndroidWorkspaceBack } from "../workspace/useAndroidWorkspaceNavigation";
import { EmptyState } from "../../components/EmptyState";
import { SegmentedControl } from "../../components/SegmentedControl";
import { SymbolView } from "../../components/AppSymbol";
@@ -269,9 +273,18 @@ function PullRequestOverview(props: {
);
}
+function usePullRequestToolbarHeight(bottomInset: number) {
+ const { buttonSize } = useAndroidControlSizing();
+ // Reserve the controls, native toolbar padding, and outer gaps before onLayout measures them.
+ return useState(() => (Platform.OS === "android" ? bottomInset + buttonSize + 40 : 0));
+}
+
export function PullRequestRouteScreen({ route }: StaticScreenProps) {
+ const returnToList = useAndroidWorkspaceBack("pullRequests");
+ const { layout } = useAdaptiveWorkspaceLayout();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
+ const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
const params = route.params;
const connected = usePullRequestConnected(params.environmentId);
const [section, setSection] = useState<"overview" | "activity" | "guide">("overview");
@@ -356,7 +369,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps
-
+
navigation.goBack()}
+ subtitle={params.repository}
+ onBack={layout.usesSplitView ? undefined : returnToList}
+ leading={layout.usesSplitView ? : undefined}
actions={[
...(permissions.verdicts.length
? [
@@ -442,7 +457,15 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps
setViewportHeight(event.nativeEvent.layout.height)}
>
{(["overview", "guide", "activity"] as const)
@@ -499,7 +522,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps
))}
-
+
);
@@ -685,8 +713,10 @@ export function PullRequestFileRouteScreen({
readonly focus?: GuideDiffFocus;
}
>) {
+ const { layout } = useAdaptiveWorkspaceLayout();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
+ const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
const commit = route.params.commit;
const result = usePullRequestDiff(route.params);
const filter = usePullRequestFileFilter(
@@ -875,7 +905,7 @@ export function PullRequestFileRouteScreen({
const seekingFile = !targetFile && result.nextCursor !== null;
const loadingDiff = (result.isPending && !data.rows.length) || (seekingFile && !result.error);
return (
-
+
) : null}
-
+
{loadingDiff || result.error ? (
@@ -1097,7 +1135,7 @@ export function PullRequestFileRouteScreen({
rowHeight={nativeReviewDiffStyle.rowHeight}
contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
contentInsetTop={Platform.OS === "ios" ? (staleDiff ? 0 : insets.top) : undefined}
- contentInsetBottom={Platform.OS === "ios" ? insets.bottom : undefined}
+ contentInsetBottom={Platform.OS === "android" ? toolbarHeight : insets.bottom}
contentResetKey={`pull-request:${route.params.environmentId}:${route.params.repository}:${route.params.number}:${commit ?? "all"}`}
/>
@@ -1147,6 +1185,7 @@ export function PullRequestFileRouteScreen({
params={route.params}
filePath={visibleFile?.path}
onOpenFiles={() => navigation.navigate("PullRequestFiles", route.params)}
+ onHeightChange={setToolbarHeight}
/>
);
diff --git a/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx b/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx
index 341f1935e45e12d089db4c28cbf34dc8c109a734..fc53e9b4f36de16bb66672abca2cacad1ea06269 100644
--- a/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx
+++ b/apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx
@@ -1,9 +1,12 @@
import { useNavigation } from "@react-navigation/native";
import type { ReactNode } from "react";
-import { ScrollView, View } from "react-native";
+import { Platform, ScrollView, useWindowDimensions, View } from "react-native";
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
+import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
+import { MaterialIconButton } from "../../components/MaterialIconButton";
+import { MaterialScreenContent } from "../../components/MaterialScreenContent";
import { ControlPill } from "../../components/ControlPill";
import { NativeStackScreenOptions } from "../../native/StackHeader";
@@ -17,44 +20,97 @@ export function PullRequestSheetContent(props: {
}) {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
- return (
-
-
-
- navigation.goBack()}
+ const { height: windowHeight } = useWindowDimensions();
+ const android = Platform.OS === "android";
+ const fitToContents = android && props.scrollable !== false;
+ const footer = props.footer ? (
+
+ {android ? {props.footer} : props.footer}
+
+ ) : null;
+ const content = (
+ <>
+ {android ? (
+ navigation.goBack()}
+ />
+ }
/>
-
- {props.title}
-
- {props.subtitle}
-
-
-
-
- {props.scrollable === false ? (
-
- {props.children}
-
) : (
-
-
- {props.children}
+
+ navigation.goBack()}
+ />
+
+ {props.title}
+
+ {props.subtitle}
+
-
+
+
)}
- {props.footer ? (
-
- {props.footer}
-
- ) : null}
+
+ {props.scrollable === false ? (
+
+ {props.children}
+
+ ) : (
+
+
+ {props.children}
+ {/* Android partial-height sheets keep submit actions within the scrollable form. */}
+ {android ? footer : null}
+
+
+ )}
+
+ {!android || props.scrollable === false ? footer : null}
+
+ >
+ );
+ return (
+
+
+ {content}
);
}
diff --git a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx
index 18efc9dfd85bb7cc5ea979a9a711bca0ebe0fdee..4a8c1be20b71584b6029c5aeb89360ea24e63b4c 100644
--- a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx
+++ b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx
@@ -1,6 +1,7 @@
import { StackActions, useNavigation } from "@react-navigation/native";
import { useState } from "react";
-import { View } from "react-native";
+import { Platform, View } from "react-native";
+import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
import { AppText as Text } from "../../components/AppText";
import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls";
import { pullRequestStatus } from "./pullRequestStatus";
@@ -12,6 +13,7 @@ export function PullRequestStackCard(props: {
readonly state: PullRequestStackState;
}) {
const navigation = useNavigation();
+ const selectAndroidWorkspace = useAndroidWorkspaceSelection();
const [expanded, setExpanded] = useState(false);
const stack = props.state.stack;
if (!stack) {
@@ -62,13 +64,12 @@ export function PullRequestStackCard(props: {
onPress={
selected
? undefined
- : () =>
- navigation.dispatch(
- StackActions.push("PullRequest", {
- ...props.params,
- number: layer.number,
- }),
- )
+ : () => {
+ const params = { ...props.params, number: layer.number };
+ if (Platform.OS === "android")
+ selectAndroidWorkspace({ name: "PullRequest", params });
+ else navigation.dispatch(StackActions.push("PullRequest", params));
+ }
}
last={index === stack.layers.length - 1}
/>
diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx
index f32a36357262dde717af664956ad98394af5df8d..d2a5c7df67da9652daf7010109d02423a68b9201 100644
--- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx
+++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx
@@ -36,6 +36,7 @@ import {
} from "./pullRequestList";
import { useDebouncedValue } from "../../state/queries";
import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
+import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
function PullRequestScrollView(props: ScrollViewProps) {
const insets = useSafeAreaInsets();
@@ -77,19 +78,22 @@ export function PullRequestsScreen() {
const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)
? selectedEnvironmentId
: null;
+ const selectAndroidWorkspace = useAndroidWorkspaceSelection();
const selectPullRequest = useCallback(
(item: EnvironmentPullRequestListEntry) => {
Keyboard.dismiss();
- navigation.navigate("PullRequest", {
+ const params = {
environmentId: String(item.environmentId),
projectId: String(item.projectId),
repository: item.repository,
number: item.number,
host: item.host,
initialSummary: item,
- });
+ };
+ if (Platform.OS === "android") selectAndroidWorkspace({ name: "PullRequest", params });
+ else navigation.navigate("PullRequest", params);
},
- [navigation],
+ [navigation, selectAndroidWorkspace],
);
const controlsAtom = pullRequestListControlsAtom(environmentId ?? "");
const controls = useAtomValue(controlsAtom);
diff --git a/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts b/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts
index 60ad24bc5c36786a2b15f37e5ad45014d4cc8199..cb63c186248313c16aca11d7d36883b2bbbb0731 100644
--- a/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts
+++ b/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts
@@ -16,6 +16,7 @@ import {
} from "./pullRequestActions";
import { scopedPullRequestListKey, type EnvironmentPullRequestListEntry } from "./pullRequestList";
import { pullRequestTarget } from "./usePullRequestDiff";
+import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
export function usePullRequestListActions(
environmentIds: readonly EnvironmentId[],
@@ -26,6 +27,7 @@ export function usePullRequestListActions(
) {
const registry = use(RegistryContext);
const navigation = useNavigation();
+ const selectAndroidWorkspace = useAndroidWorkspaceSelection();
const permissionKey = JSON.stringify(environmentIds);
const permissionAtom = useMemo(() => {
const ids: readonly EnvironmentId[] = JSON.parse(permissionKey);
@@ -74,7 +76,9 @@ export function usePullRequestListActions(
!pr.viewerPermissions.requestReviewers
)
throw new Error("Review requests aren't available for this pull request.");
- navigation.navigate("PullRequestReviewers", params(entry));
+ if (Platform.OS === "android") {
+ selectAndroidWorkspace({ name: "PullRequest", params: params(entry) }, params(entry));
+ } else navigation.navigate("PullRequestReviewers", params(entry));
return;
}
if (
@@ -102,12 +106,14 @@ export function usePullRequestListActions(
setBusyKey(null);
}
},
- [registry, navigation, params, run, onChanged],
+ [registry, navigation, params, run, onChanged, selectAndroidWorkspace],
);
const select = useCallback(
(entry: EnvironmentPullRequestListEntry, action: string) => {
if (action === "open") {
- navigation.navigate("PullRequest", params(entry));
+ if (Platform.OS === "android")
+ selectAndroidWorkspace({ name: "PullRequest", params: params(entry) });
+ else navigation.navigate("PullRequest", params(entry));
return;
}
if (action === "request-review") {
@@ -143,7 +149,7 @@ export function usePullRequestListActions(
},
]);
},
- [navigation, params, perform],
+ [navigation, params, perform, selectAndroidWorkspace],
);
const actions = useCallback(
(entry: EnvironmentPullRequestListEntry): ControlPillMenuProps["actions"] => [
diff --git a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts
index d38ec7d0bc2e9d23ae9281c009be904386ece5f4..8bd406962ddcf7946fa1e15fe7da1d4344143dfb 100644
--- a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts
+++ b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts
@@ -11,7 +11,8 @@ import { formatComposerContextReference } from "@t3tools/shared/composerContextR
import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
import { useRef, useState } from "react";
-import { Alert } from "react-native";
+import { Alert, Platform } from "react-native";
+import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
import { pullRequestComposerContext } from "../../lib/composerContext";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { uuidv4 } from "../../lib/uuid";
@@ -70,10 +71,14 @@ export function usePullRequestThreads(
const canAsk = !connected || canOperate;
const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope);
const navigation = useNavigation();
+ const selectAndroidWorkspace = useAndroidWorkspaceSelection();
const pending = useRef(false);
const previousPrompts = useRef(new Map());
- const openThread = (thread: LinkedThread) =>
- navigation.navigate("Thread", { environmentId: params.environmentId, threadId: thread.id });
+ const openThread = (thread: LinkedThread) => {
+ const destination = { environmentId: params.environmentId, threadId: thread.id };
+ if (Platform.OS === "android") selectAndroidWorkspace({ name: "Thread", params: destination });
+ else navigation.navigate("Thread", destination);
+ };
const [busy, setBusy] = useState(false);
const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => {
if (!pr || !canAsk || pending.current) return;
diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx
index 9b0696a5d3f7e0cf32ac7407b98ac6e72bc35f7d..5da9c93a7f9ddc2abd0fd1e45c60764812c0fdba 100644
--- a/apps/mobile/src/features/threads/ThreadHeader.tsx
+++ b/apps/mobile/src/features/threads/ThreadHeader.tsx
@@ -1,4 +1,3 @@
-import { StackActions, useNavigation } from "@react-navigation/native";
import { useMemo } from "react";
import { ScreenHeader } from "../../components/ScreenHeader";
import { ScreenHeaderButton } from "../../components/ScreenHeaderButton";
@@ -6,6 +5,7 @@ import type { ScreenHeaderAction } from "../../components/ScreenHeader.types";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import type { ThreadInspectorMode } from "./thread-inspector-content-stack";
import { useThreadHeaderOptions } from "./useThreadHeaderOptions";
+import { useWorkspaceListNavigation } from "../workspace/useAndroidWorkspaceNavigation";
export function ThreadHeader(
props: Parameters[0] & {
@@ -18,7 +18,7 @@ export function ThreadHeader(
readonly onOpenFilesInspector: () => void;
},
) {
- const navigation = useNavigation();
+ const returnToList = useWorkspaceListNavigation("threads");
const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
const { onOpenTerminal, onMergeBack } = props.gitControls;
const native = useThreadHeaderOptions(props);
@@ -96,16 +96,7 @@ export function ThreadHeader(
/>
) : null
}
- onBack={
- layout.usesSplitView
- ? undefined
- : () => {
- // A deep link or cold start has no previous route; Home is the way out.
- // Read the history at press time: it changes without re-rendering this screen.
- if (navigation.canGoBack()) navigation.goBack();
- else navigation.dispatch(StackActions.replace("Home"));
- }
- }
+ onBack={layout.usesSplitView ? undefined : returnToList}
actions={androidHeaderActions}
hideBottomBorder
/>
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index aa0ac90ec9bd83ec409ca5cfe9669ff30295269c..22caee1aadc9c0c5dcbd38bb4156b138556b68ff 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -87,6 +87,10 @@ import {
} from "./thread-inspector-content-stack";
import { threadRouteIsHydrating } from "./thread-route-hydration";
import { ThreadHeader } from "./ThreadHeader";
+import {
+ useAndroidWorkspaceBack,
+ useWorkspaceListNavigation,
+} from "../workspace/useAndroidWorkspaceNavigation";
interface ThreadInspectorSelection {
readonly routeThreadIdentity: string | null;
@@ -114,7 +118,7 @@ function ThreadPane(props: { readonly children: ReactNode }) {
}
function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) {
- const navigation = useNavigation();
+ const returnToList = useWorkspaceListNavigation("threads");
const { layout } = useAdaptiveWorkspaceLayout();
const { themeVariables } = useAppearancePreferences();
if (Platform.OS !== "android") return props.children;
@@ -124,14 +128,7 @@ function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) {
{
- if (navigation.canGoBack()) navigation.goBack();
- else navigation.dispatch(StackActions.replace("Home"));
- }
- }
+ onBack={layout.usesSplitView ? undefined : returnToList}
hideBottomBorder
/>
{props.children}
@@ -190,6 +187,7 @@ function ThreadUnavailableScreen(props: {
}
export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
+ useAndroidWorkspaceBack("threads");
const connectionsReady = useConnectionsReady();
const { connectionState } = useRemoteConnectionStatus();
const { selectedThread } = useThreadSelection();
diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7bcfb936b9feb18d463451262307aed1da8c795a
--- /dev/null
+++ b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx
@@ -0,0 +1,98 @@
+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 { flattenThemeColor, themeColorWithAlpha } from "../../lib/mobileTheme";
+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 = rail
+ ? colors["--color-header"]
+ : flattenThemeColor(
+ themeColorWithAlpha(colors["--color-header-foreground"], 0.05),
+ flattenThemeColor(colors["--color-header"], colors["--color-screen"]),
+ );
+ 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
new file mode 100644
index 0000000000000000000000000000000000000000..a3da818b21c5facc86100a7ca304e1efc33bd086
--- /dev/null
+++ b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx
@@ -0,0 +1,9 @@
+/** 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
new file mode 100644
index 0000000000000000000000000000000000000000..8d65314da12d5fa9ad52e83cd4b4eba71260376c
--- /dev/null
+++ b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx
@@ -0,0 +1,26 @@
+import { useMemo, type ReactNode } from "react";
+import { View } from "react-native";
+import { MaterialScreenContent } from "../../components/MaterialScreenContent";
+import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen";
+import { MaterialPullRequestToolbar } from "../pull-requests/MaterialPullRequestToolbar.android";
+import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";
+
+export function AndroidWorkspaceSidebar(props: {
+ readonly children: ReactNode;
+ readonly onRequestVisibility: () => void;
+}) {
+ const workspace = useWorkspaceTabs()!;
+ const context = useMemo(() => ({ ...workspace, bottomClearance: 16 }), [workspace]);
+ return workspace.state.selectedTab === "threads" ? (
+ props.children
+ ) : (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b31fadadbce22d94a235e423fa40df7a675db603
--- /dev/null
+++ b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.tsx
@@ -0,0 +1,8 @@
+import type { ReactNode } from "react";
+
+export function AndroidWorkspaceSidebar(props: {
+ readonly children: ReactNode;
+ readonly onRequestVisibility: () => void;
+}) {
+ return props.children;
+}
diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceTabsProvider.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceTabsProvider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2655b55b308e30d42f868bcacf5f6dfe3ab4d0a9
--- /dev/null
+++ b/apps/mobile/src/features/workspace/AndroidWorkspaceTabsProvider.tsx
@@ -0,0 +1,12 @@
+import type { ReactNode } from "react";
+import { Platform } from "react-native";
+import { WorkspaceTabsProvider } from "./WorkspaceTabsContext";
+
+/** Android's persistent sidebar and compact root share one tab controller. */
+export function AndroidWorkspaceTabsProvider(props: { readonly children: ReactNode }) {
+ return Platform.OS === "android" ? (
+ {props.children}
+ ) : (
+ props.children
+ );
+}
diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3e6f31cb590249cbbe55113b0138718cf8af92eb
--- /dev/null
+++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx
@@ -0,0 +1,70 @@
+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";
+import { MaterialScreenContent } from "../../components/MaterialScreenContent";
+import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
+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(
+ () => ({
+ ...workspace,
+ bottomClearance: workspace.state.selectedTab === "threads" ? fabClearance + 16 : 16,
+ }),
+ [workspace, fabClearance],
+ );
+
+ // Split lists live beside the navigator; Home remains an empty detail pane.
+ if (layout.usesSplitView) {
+ return workspace.state.selectedTab === "threads" ? (
+
+ ) : (
+ <>
+
+
+
+
+
+ >
+ );
+ }
+
+ return (
+
+
+
+
+
+ {workspace.state.selectedTab === "threads" ? (
+
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts b/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3d474eadb4784819cdcd962e498d0717e5d1d351
--- /dev/null
+++ b/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts
@@ -0,0 +1,42 @@
+import {
+ CommonActions,
+ StackActions,
+ useFocusEffect,
+ useNavigation,
+} from "@react-navigation/native";
+import { useCallback } from "react";
+import { BackHandler, Keyboard, Platform } from "react-native";
+import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation";
+import { useWorkspaceTabs } from "./WorkspaceTabsContext";
+import type { WorkspaceTab } from "./workspace-tabs-state";
+
+export function useWorkspaceListNavigation(tab: WorkspaceTab) {
+ const navigation = useNavigation();
+ const dispatch = useWorkspaceTabs()?.dispatch;
+ return useCallback(() => {
+ if (Platform.OS === "android") {
+ dispatch?.({ type: "select", tab });
+ navigation.dispatch((state) => CommonActions.reset(androidWorkspaceNavigationState(state)));
+ } else if (navigation.canGoBack()) navigation.goBack();
+ else navigation.dispatch(StackActions.replace("Home"));
+ }, [navigation, dispatch, tab]);
+}
+
+/** Only base details own this Back handler; focused sheets and nested screens take priority. */
+export function useAndroidWorkspaceBack(tab: WorkspaceTab) {
+ const dispatch = useWorkspaceTabs()?.dispatch;
+ const returnToList = useWorkspaceListNavigation(tab);
+ useFocusEffect(
+ useCallback(() => {
+ if (Platform.OS !== "android") return;
+ dispatch?.({ type: "select", tab });
+ const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
+ if (Keyboard.isVisible()) Keyboard.dismiss();
+ else returnToList();
+ return true;
+ });
+ return () => subscription.remove();
+ }, [returnToList, dispatch, tab]),
+ );
+ return returnToList;
+}
diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts
new file mode 100644
index 0000000000000000000000000000000000000000..46f49e9c071e54644d2cb7d37dc33a8dff6f30a0
--- /dev/null
+++ b/apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts
@@ -0,0 +1,29 @@
+import { CommonActions, useNavigation } from "@react-navigation/native";
+import { useCallback } from "react";
+import { Keyboard } from "react-native";
+import {
+ androidWorkspaceNavigationState,
+ type AndroidWorkspaceDestination,
+} from "../../lib/android-workspace-navigation";
+import { useWorkspaceTabs } from "./WorkspaceTabsContext";
+
+export function useAndroidWorkspaceSelection() {
+ const navigation = useNavigation();
+ const dispatch = useWorkspaceTabs()?.dispatch;
+ return useCallback(
+ (
+ destination: AndroidWorkspaceDestination,
+ reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"],
+ ) => {
+ Keyboard.dismiss();
+ dispatch?.({
+ type: "select",
+ tab: destination.name === "Thread" ? "threads" : "pullRequests",
+ });
+ navigation.dispatch((state) =>
+ CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)),
+ );
+ },
+ [navigation, dispatch],
+ );
+}
diff --git a/apps/mobile/src/lib/android-workspace-navigation.test.ts b/apps/mobile/src/lib/android-workspace-navigation.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a9bdef84e607fab73570f35ac19bbfe168ac9e6e
--- /dev/null
+++ b/apps/mobile/src/lib/android-workspace-navigation.test.ts
@@ -0,0 +1,186 @@
+import * as NodeModule from "node:module";
+import { describe, expect, it } from "vite-plus/test";
+import {
+ androidWorkspaceNavigationState,
+ type AndroidWorkspaceDestination,
+} from "./android-workspace-navigation";
+
+const require = NodeModule.createRequire(import.meta.url);
+const nativePackage = require.resolve("@react-navigation/native/package.json");
+const requireFromNative = NodeModule.createRequire(nativePackage);
+const corePackage = requireFromNative.resolve("@react-navigation/core/package.json");
+const requireFromCore = NodeModule.createRequire(corePackage);
+const { CommonActions, StackActions, StackRouter } = requireFromCore(
+ "@react-navigation/routers",
+) as {
+ readonly CommonActions: typeof import("@react-navigation/native").CommonActions;
+ readonly StackActions: typeof import("@react-navigation/native").StackActions;
+ readonly StackRouter: typeof import("@react-navigation/native").StackRouter;
+};
+const router = StackRouter({});
+const options = {
+ routeNames: [
+ "Home",
+ "Thread",
+ "ThreadFiles",
+ "ThreadTerminal",
+ "GitOverview",
+ "PullRequest",
+ "PullRequestFile",
+ "PullRequestReviewers",
+ "SettingsSheet",
+ ],
+ routeParamList: {},
+ routeGetIdList: {},
+};
+const thread = {
+ name: "Thread",
+ params: { environmentId: "environment-a", threadId: "thread-a" },
+} as const;
+const pullRequest = {
+ name: "PullRequest",
+ params: {
+ environmentId: "environment-a",
+ projectId: "project-a",
+ repository: "owner/repository",
+ number: 42,
+ host: "github.com",
+ },
+} as const;
+
+function apply(
+ state: ReturnType,
+ action: Parameters[1],
+) {
+ const result = router.getStateForAction(state, action, options);
+ if (!result) throw new Error(`Router rejected ${action.type}`);
+ return router.getRehydratedState(result, options);
+}
+
+function select(
+ state: ReturnType,
+ destination?: AndroidWorkspaceDestination,
+ reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"],
+) {
+ return apply(
+ state,
+ CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)),
+ );
+}
+
+function legacyHistory() {
+ let state = router.getInitialState(options);
+ state = apply(state, StackActions.push(thread.name, thread.params));
+ state = apply(state, StackActions.push(pullRequest.name, pullRequest.params));
+ return state;
+}
+
+function names(state: ReturnType) {
+ return state.routes.map((route) => route.name);
+}
+
+describe("Android workspace navigation", () => {
+ it("replaces cross-tab history with one detail and returns directly to its list", () => {
+ const previous = legacyHistory();
+ const state = select(previous, pullRequest);
+ expect(names(state)).toEqual(["Home", "PullRequest"]);
+ expect(state.routes[0]?.key).toBe(previous.routes[0]?.key);
+ expect(state.routes[1]?.key).toBe(previous.routes[2]?.key);
+ expect(names(apply(state, StackActions.pop()))).toEqual(["Home"]);
+ });
+
+ it("replaces peer thread selections without leaving the previous thread underneath", () => {
+ const first = select(router.getInitialState(options), thread);
+ const second = select(first, {
+ name: "Thread",
+ params: { ...thread.params, threadId: "thread-b" },
+ });
+ expect(names(second)).toEqual(["Home", "Thread"]);
+ expect(second.routes[1]?.params).toEqual({ ...thread.params, threadId: "thread-b" });
+ expect(second.routes[1]?.key).not.toBe(first.routes[1]?.key);
+ expect(names(apply(second, StackActions.pop()))).toEqual(["Home"]);
+ });
+
+ it("dismisses nested content and overlays when the sidebar selects a peer", () => {
+ let state = legacyHistory();
+ state = apply(
+ state,
+ StackActions.push("PullRequestFile", { ...pullRequest.params, path: "src/main.ts" }),
+ );
+ state = apply(state, StackActions.push("SettingsSheet"));
+ const selected = select(state, thread);
+ expect(names(selected)).toEqual(["Home", "Thread"]);
+ expect(selected.routes[1]?.params).toEqual(thread.params);
+ });
+
+ it("creates Home beneath cold-linked details and gives direct list escape", () => {
+ const initial = router.getInitialState(options);
+ const cold = apply(initial, CommonActions.reset({ index: 0, routes: [pullRequest] }));
+ expect(names(select(cold, pullRequest))).toEqual(["Home", "PullRequest"]);
+ expect(names(select(cold))).toEqual(["Home"]);
+ });
+
+ it("keeps a request-review flow above the chosen PR instead of an old chat", () => {
+ const state = select(legacyHistory(), pullRequest, pullRequest.params);
+ expect(names(state)).toEqual(["Home", "PullRequest", "PullRequestReviewers"]);
+ const back = apply(state, StackActions.pop());
+ expect(names(back)).toEqual(["Home", "PullRequest"]);
+ expect(back.routes[back.index]?.params).toEqual(pullRequest.params);
+ });
+
+ it("preserves normal nested PR Back behavior", () => {
+ const detail = select(legacyHistory(), pullRequest);
+ const nested = apply(
+ detail,
+ StackActions.push("PullRequestFile", { ...pullRequest.params, path: "file.ts" }),
+ );
+ const back = apply(nested, StackActions.pop());
+ expect(back.routes[back.index]?.key).toBe(detail.routes[detail.index]?.key);
+ expect(names(back)).toEqual(["Home", "PullRequest"]);
+ });
+
+ it.each(["ThreadFiles", "ThreadTerminal", "GitOverview"])(
+ "returns to chat when its sidebar row is selected from %s",
+ (route) => {
+ const detail = select(router.getInitialState(options), thread);
+ const nested = apply(detail, StackActions.push(route, thread.params));
+ const selected = select(nested, thread);
+ expect(names(selected)).toEqual(["Home", "Thread"]);
+ expect(selected.routes[1]?.key).toBe(detail.routes[1]?.key);
+ expect(names(apply(selected, StackActions.pop()))).toEqual(["Home"]);
+ },
+ );
+
+ it.each([
+ { environmentId: "environment-b" },
+ { projectId: "project-b" },
+ { repository: "owner/other" },
+ { host: "git.example.test" },
+ { number: 43 },
+ ])("does not reuse another PR's key when its scope differs: %o", (change) => {
+ const previous = select(router.getInitialState(options), pullRequest);
+ const state = select(previous, {
+ name: "PullRequest",
+ params: { ...pullRequest.params, ...change },
+ });
+ expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key);
+ expect(state.routes[1]?.params).toEqual({ ...pullRequest.params, ...change });
+ });
+
+ it("does not reuse a thread key from another environment", () => {
+ const previous = select(router.getInitialState(options), thread);
+ const state = select(previous, {
+ name: "Thread",
+ params: { ...thread.params, environmentId: "environment-b" },
+ });
+ expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key);
+ });
+
+ it("drops stale detail history on a tab switch while retaining Home", () => {
+ const previous = legacyHistory();
+ const state = select(previous);
+ expect(names(state)).toEqual(["Home"]);
+ expect(state.routes[0]?.key).toBe(previous.routes[0]?.key);
+ expect(state.index).toBe(0);
+ });
+});
diff --git a/apps/mobile/src/lib/android-workspace-navigation.ts b/apps/mobile/src/lib/android-workspace-navigation.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0738e04a852f5650eee1d33d2c0eee783e221078
--- /dev/null
+++ b/apps/mobile/src/lib/android-workspace-navigation.ts
@@ -0,0 +1,34 @@
+import type { NavigationState } from "@react-navigation/native";
+
+export type AndroidWorkspaceDestination =
+ | { readonly name: "Thread"; readonly params: ReactNavigation.RootParamList["Thread"] }
+ | { readonly name: "PullRequest"; readonly params: ReactNavigation.RootParamList["PullRequest"] };
+
+/** Peer details share one slot above Home; nested routes retain ordinary back navigation. */
+export function androidWorkspaceNavigationState(
+ state: NavigationState,
+ destination?: AndroidWorkspaceDestination,
+ reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"],
+) {
+ const home = state.routes.find((route) => route.name === "Home") ?? { name: "Home" };
+ if (!destination) return { ...state, index: 0, routes: [home] };
+ const identity =
+ destination.name === "Thread"
+ ? ["environmentId", "threadId"]
+ : ["environmentId", "projectId", "repository", "number", "host"];
+ const params = destination.params as Readonly>;
+ const existing = state.routes.findLast((route) => {
+ if (route.name !== destination.name) return false;
+ const current = route.params as Readonly> | undefined;
+ return identity.every((key) => current?.[key] === params[key]);
+ });
+ return {
+ ...state,
+ index: reviewers ? 2 : 1,
+ routes: [
+ home,
+ { ...existing, ...destination },
+ ...(reviewers ? [{ name: "PullRequestReviewers", params: reviewers }] : []),
+ ],
+ };
+}
diff --git a/apps/mobile/src/native/NativeHeaderToolbar.tsx b/apps/mobile/src/native/NativeHeaderToolbar.tsx
index ec0157acf5cb1cc6a4c3a8e84df04be4d5abff29..b05c498903a4196b0122d1fe522a7b53e82d2455 100644
--- a/apps/mobile/src/native/NativeHeaderToolbar.tsx
+++ b/apps/mobile/src/native/NativeHeaderToolbar.tsx
@@ -49,10 +49,17 @@ type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {
};
function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {
- if (typeof icon !== "string") {
- return undefined;
+ if (typeof icon === "string") return { type: "sfSymbol", name: icon as never };
+ if (
+ typeof icon === "object" &&
+ icon !== null &&
+ "type" in icon &&
+ icon.type === "image" &&
+ "source" in icon
+ ) {
+ return icon as NativeStackHeaderIcon;
}
- return { type: "sfSymbol", name: icon as never };
+ return undefined;
}
type ToolbarElementProps = Record & { readonly children?: ReactNode };
@@ -275,7 +282,7 @@ function NativeHeaderToolbarButton(_props: {
readonly accessibilityLabel?: string;
readonly axisBehavior?: NativeStackHeaderAxisBehavior;
readonly disabled?: boolean;
- readonly icon?: string;
+ readonly icon?: string | Extract;
readonly label?: string;
readonly onPress?: () => void;
readonly separateBackground?: boolean;
diff --git a/apps/mobile/src/native/useOpenPullRequest.ts b/apps/mobile/src/native/useOpenPullRequest.ts
index 5ebdf606e051db96e2dfc64188fcb6acf0247582..fb34417c977409c9c2761afed7b591186494811c 100644
--- a/apps/mobile/src/native/useOpenPullRequest.ts
+++ b/apps/mobile/src/native/useOpenPullRequest.ts
@@ -1,6 +1,9 @@
import { useNavigation, useRoute, StackActions } from "@react-navigation/native";
import { nativePullRequestDestination } from "../lib/pullRequestNavigation";
import { useCallback } from "react";
+import { Platform } from "react-native";
+import { CommonActions } from "@react-navigation/native";
+import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { useThreadShell } from "../state/entities";
import { tryOpenExternalUrl } from "../lib/openExternalUrl";
@@ -42,7 +45,13 @@ export function useOpenPullRequest(options?: {
: null,
);
if (!params) return tryOpenExternalUrl(url, "pull-request");
- if (replace) navigation.dispatch(StackActions.replace("PullRequest", params));
+ if (Platform.OS === "android")
+ navigation.dispatch((state) =>
+ CommonActions.reset(
+ androidWorkspaceNavigationState(state, { name: "PullRequest", params }),
+ ),
+ );
+ else if (replace) navigation.dispatch(StackActions.replace("PullRequest", params));
else navigation.navigate("PullRequest", params);
return true;
},