From 397a46123999ada6b9ddcbe85534631a241bb0ed Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 11 Oct 2026 19:27:54 -0700 Subject: [PATCH] feat(android): add Pull Requests tab (#18372) --- apps/mobile/assets/icons/file-diff.png | Bin 0 -> 248 bytes apps/mobile/assets/icons/file-diff@2x.png | Bin 0 -> 418 bytes apps/mobile/assets/icons/file-diff@3x.png | Bin 0 -> 588 bytes .../t3-native-controls/android/build.gradle | 11 ++ .../T3WorkspaceNavigationModule.kt | 184 +++++++++++++++++ .../src/main/res/drawable/t3_chats.xml | 3 + .../main/res/drawable/t3_pull_requests.xml | 3 + .../expo-module.config.json | 1 + .../t3reviewdiff/T3ReviewDiffModule.kt | 3 + .../modules/t3reviewdiff/T3ReviewDiffView.kt | 16 +- apps/mobile/src/Stack.tsx | 33 ++-- apps/mobile/src/components/AppSymbol.ios.tsx | 10 +- apps/mobile/src/components/AppSymbol.tsx | 12 +- .../src/features/home/HomeHeader.android.tsx | 2 - .../src/features/home/HomeRouteScreen.tsx | 4 +- apps/mobile/src/features/home/HomeScreen.tsx | 2 +- .../home/MaterialThreadListToolbar.tsx | 23 ++- .../home/home-thread-navigation.test.ts | 2 + .../features/home/home-thread-navigation.ts | 12 +- .../layout/AdaptiveWorkspaceLayout.tsx | 162 ++++++++++++--- .../layout/workspace-sidebar-toolbar.tsx | 2 +- .../MaterialFloatingToolbar.android.tsx | 26 +++ .../pull-requests/MaterialFloatingToolbar.tsx | 5 + .../MaterialPullRequestToolbar.android.tsx | 116 +++++++++++ .../pull-requests/PullRequestControls.tsx | 78 ++++---- .../PullRequestMergeControls.tsx | 101 ++++++---- .../pull-requests/PullRequestReviewSheet.tsx | 4 +- .../pull-requests/PullRequestRouteScreen.tsx | 57 +++++- .../pull-requests/PullRequestSheetContent.tsx | 126 ++++++++---- .../pull-requests/PullRequestStackCard.tsx | 17 +- .../pull-requests/PullRequestsScreen.tsx | 10 +- .../usePullRequestListActions.ts | 14 +- .../pull-requests/usePullRequestThreads.ts | 11 +- .../src/features/threads/ThreadHeader.tsx | 15 +- .../features/threads/ThreadRouteScreen.tsx | 16 +- .../AndroidWorkspaceNavigation.android.tsx | 98 +++++++++ .../workspace/AndroidWorkspaceNavigation.tsx | 9 + .../AndroidWorkspaceSidebar.android.tsx | 26 +++ .../workspace/AndroidWorkspaceSidebar.tsx | 8 + .../AndroidWorkspaceTabsProvider.tsx | 12 ++ .../workspace/WorkspaceRootScreen.android.tsx | 70 +++++++ .../useAndroidWorkspaceNavigation.ts | 42 ++++ .../workspace/useAndroidWorkspaceSelection.ts | 29 +++ .../lib/android-workspace-navigation.test.ts | 186 ++++++++++++++++++ .../src/lib/android-workspace-navigation.ts | 34 ++++ .../mobile/src/native/NativeHeaderToolbar.tsx | 15 +- apps/mobile/src/native/useOpenPullRequest.ts | 11 +- 47 files changed, 1399 insertions(+), 222 deletions(-) create mode 100644 apps/mobile/assets/icons/file-diff.png create mode 100644 apps/mobile/assets/icons/file-diff@2x.png create mode 100644 apps/mobile/assets/icons/file-diff@3x.png create mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt create mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chats.xml create mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_pull_requests.xml create mode 100644 apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx create mode 100644 apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.tsx create mode 100644 apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx create mode 100644 apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx create mode 100644 apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx create mode 100644 apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx create mode 100644 apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.tsx create mode 100644 apps/mobile/src/features/workspace/AndroidWorkspaceTabsProvider.tsx create mode 100644 apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx create mode 100644 apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts create mode 100644 apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts create mode 100644 apps/mobile/src/lib/android-workspace-navigation.test.ts create mode 100644 apps/mobile/src/lib/android-workspace-navigation.ts 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 GIT binary patch literal 248 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-#VSu1 z#}JRsRL-@piE9Rp7- zie46HTE*`J&%(toX5w4}U>KqJMSN+@#KcQ_3mgXcB!l?y5*`KmB%2B2g_MsiTBRpq z0i0?p)v)a ztJ`Q@PU2sck2zmu7fL-~bI&m3&-5l2DW(xNcWaG3(;J|Nyn4W$m+Mu+_-cdgQ91?^ z00+QZg5vMp#iEE#fJepRA7U(1I~o_j%aU6giI_X@f#k>+745oy0G`fzUu9_p2mk;8 M07*qoM6N<$f`@&f`2YX_ literal 0 HcmV?d00001 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 GIT binary patch literal 588 zcmV-S0<-;zP)$VeF%X7-nuR73(9sh(1vKF%&_Zs))(NM80#P6y z2cU&eQBfvENI}J*l^ls;9kVMx#+(0>MxyczpKMQdBm;yHLdd#v0$>ba0$>3^0>lr1 z7XUqE!~uZkKtgQs0bmmua13A;Sxs&FW0)6hBC0QOfu|&*VsxNL)Am~Q3BvBx!&@4$52!duw zqC}8dNuo%QN=Xt?&}Yj|W}5=^x+dtxqaZQ!q)rJs3ovx+l%Nd&_nsvMM9sH|=g&3MyNOY1`eh6g2f9=)c{Kg1RWEH?r^|?Spu^Bp%@bi>Y|{6n zM|_IVmR(DYRqerS`RV + 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; }, -- 2.54.0