authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-10-11 19:27:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 19:27:54-07:00
log397a46123999ada6b9ddcbe85534631a241bb0ed
treef43751bd71c0d65c88dc263de6396aa561d27fa5
parentf79f37a637bda257faa06a04184e368f78650d3d
signature Signed by PGP key B5690EEEBB952194

feat(android): add Pull Requests tab (#18372)


47 files changed, 1399 insertions(+), 222 deletions(-)

apps/mobile/assets/icons/file-diff.png created
Binary files /dev/null and b/apps/mobile/assets/icons/file-diff.png differ
apps/mobile/assets/icons/file-diff@2x.png created
Binary files /dev/null and b/apps/mobile/assets/icons/file-diff@2x.png differ
apps/mobile/assets/icons/file-diff@3x.png created
Binary files /dev/null and b/apps/mobile/assets/icons/file-diff@3x.png differ
apps/mobile/modules/t3-native-controls/android/build.gradle+11
......@@ -1,5 +1,13 @@
1buildscript {
2 repositories { mavenCentral() }
3 dependencies {
4 classpath("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:${kotlinVersion}")
5 }
6}
7
18apply plugin: 'com.android.library'
29apply plugin: 'org.jetbrains.kotlin.android'
10apply plugin: 'org.jetbrains.kotlin.plugin.compose'
311
412group = 'com.t3tools.nativecontrols'
513version = '0.0.0'
......@@ -12,8 +20,11 @@ android {
1220 minSdkVersion rootProject.ext.minSdkVersion
1321 targetSdkVersion rootProject.ext.targetSdkVersion
1422 }
23 buildFeatures { compose true }
1524}
1625
1726dependencies {
1827 implementation project(':expo-modules-core')
28 implementation 'androidx.compose.ui:ui-android:1.10.6'
29 implementation 'androidx.compose.material3:material3-android:1.5.0-alpha17'
1930}
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt created+184
......@@ -0,0 +1,184 @@
1package expo.modules.t3nativecontrols
2
3import android.content.Context
4import android.view.ViewGroup
5import androidx.compose.foundation.layout.WindowInsets
6import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.material3.Icon
9import androidx.compose.material3.MaterialTheme
10import androidx.compose.material3.NavigationBar
11import androidx.compose.material3.NavigationBarItem
12import androidx.compose.material3.NavigationBarItemDefaults
13import androidx.compose.material3.NavigationRail
14import androidx.compose.material3.NavigationRailItem
15import androidx.compose.material3.NavigationRailItemDefaults
16import androidx.compose.material3.Text
17import androidx.compose.material3.darkColorScheme
18import androidx.compose.material3.lightColorScheme
19import androidx.compose.runtime.Composable
20import androidx.compose.runtime.getValue
21import androidx.compose.runtime.mutableStateOf
22import androidx.compose.runtime.setValue
23import androidx.compose.ui.Modifier
24import androidx.compose.ui.graphics.Color
25import androidx.compose.ui.platform.ComposeView
26import androidx.compose.ui.platform.ViewCompositionStrategy
27import androidx.compose.ui.res.painterResource
28import androidx.compose.ui.unit.sp
29import expo.modules.kotlin.AppContext
30import expo.modules.kotlin.modules.Module
31import expo.modules.kotlin.modules.ModuleDefinition
32import expo.modules.kotlin.viewevent.EventDispatcher
33import expo.modules.kotlin.views.ExpoView
34
35class T3WorkspaceNavigationModule : Module() {
36 override fun definition() = ModuleDefinition {
37 Name("T3WorkspaceNavigation")
38 View(T3WorkspaceNavigationView::class) {
39 Prop("selectedTab") { view: T3WorkspaceNavigationView, value: String ->
40 view.selectedTab = value
41 }
42 Prop("rail") { view: T3WorkspaceNavigationView, value: Boolean -> view.rail = value }
43 Prop("dark") { view: T3WorkspaceNavigationView, value: Boolean -> view.dark = value }
44 Prop("textScale") { view: T3WorkspaceNavigationView, value: Float -> view.textScale = value }
45 Prop("containerColor") { view: T3WorkspaceNavigationView, value: Int ->
46 view.containerColor = value
47 }
48 Prop("selectedColor") { view: T3WorkspaceNavigationView, value: Int ->
49 view.selectedColor = value
50 }
51 Prop("indicatorColor") { view: T3WorkspaceNavigationView, value: Int ->
52 view.indicatorColor = value
53 }
54 Prop("unselectedColor") { view: T3WorkspaceNavigationView, value: Int ->
55 view.unselectedColor = value
56 }
57 Events("onTabSelected")
58 }
59 }
60}
61
62/** One Material3 selection surface moves from below the list to its leading edge. */
63class T3WorkspaceNavigationView(
64 context: Context,
65 appContext: AppContext
66) : ExpoView(context, appContext) {
67 // Compose must remeasure native children when Yoga resizes the folded window.
68 override val shouldUseAndroidLayout = true
69
70 private val onTabSelected by EventDispatcher()
71 var selectedTab by mutableStateOf("threads")
72 var rail by mutableStateOf(false)
73 var dark by mutableStateOf(false)
74 var textScale by mutableStateOf(1f)
75 var containerColor by mutableStateOf(0)
76 var selectedColor by mutableStateOf(0)
77 var indicatorColor by mutableStateOf(0)
78 var unselectedColor by mutableStateOf(0)
79
80 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
81 // Fabric measures the replacement bar before attaching it on a fold.
82 // Compose needs an attached window to obtain its lifecycle recomposer.
83 if (!isAttachedToWindow) {
84 setMeasuredDimension(
85 MeasureSpec.getSize(widthMeasureSpec),
86 MeasureSpec.getSize(heightMeasureSpec)
87 )
88 return
89 }
90 super.onMeasure(widthMeasureSpec, heightMeasureSpec)
91 }
92
93 override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
94 if (isAttachedToWindow) super.onLayout(changed, left, top, right, bottom)
95 }
96
97 override fun onAttachedToWindow() {
98 super.onAttachedToWindow()
99 requestLayout()
100 }
101
102 init {
103 addView(
104 ComposeView(context).apply {
105 setViewCompositionStrategy(
106 ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
107 )
108 setContent {
109 MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) {
110 val tabs = listOf("threads", "pullRequests")
111 if (rail) {
112 NavigationRail(
113 modifier = Modifier.fillMaxSize(),
114 containerColor = Color(containerColor),
115 windowInsets = WindowInsets(0),
116 ) {
117 Spacer(Modifier.weight(1f))
118 tabs.forEach { tab ->
119 NavigationRailItem(
120 selected = selectedTab == tab,
121 onClick = { onTabSelected(mapOf("tab" to tab)) },
122 icon = { tabIcon(tab) },
123 label = {
124 val title = if (tab == "threads") "Chats" else "PRs"
125 Text(title, fontSize = (12 * textScale).sp)
126 },
127 colors = NavigationRailItemDefaults.colors(
128 selectedIconColor = Color(selectedColor),
129 selectedTextColor = Color(selectedColor),
130 indicatorColor = Color(indicatorColor),
131 unselectedIconColor = Color(unselectedColor),
132 unselectedTextColor = Color(unselectedColor),
133 ),
134 )
135 }
136 Spacer(Modifier.weight(1f))
137 }
138 } else {
139 NavigationBar(
140 modifier = Modifier.fillMaxSize(),
141 containerColor = Color(containerColor),
142 tonalElevation = androidx.compose.ui.unit.Dp(0f),
143 windowInsets = WindowInsets(0),
144 ) {
145 tabs.forEach { tab ->
146 NavigationBarItem(
147 selected = selectedTab == tab,
148 onClick = { onTabSelected(mapOf("tab" to tab)) },
149 icon = { tabIcon(tab) },
150 label = {
151 val title = if (tab == "threads") "Chats" else "Pull requests"
152 Text(title, fontSize = (12 * textScale).sp)
153 },
154 colors = NavigationBarItemDefaults.colors(
155 selectedIconColor = Color(selectedColor),
156 selectedTextColor = Color(selectedColor),
157 indicatorColor = Color(indicatorColor),
158 unselectedIconColor = Color(unselectedColor),
159 unselectedTextColor = Color(unselectedColor),
160 ),
161 )
162 }
163 }
164 }
165 }
166 }
167 },
168 LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
169 )
170 }
171
172 @Composable
173 private fun tabIcon(tab: String) {
174 val drawable = if (tab == "threads") {
175 R.drawable.t3_chats
176 } else {
177 R.drawable.t3_pull_requests
178 }
179 Icon(
180 painter = painterResource(drawable),
181 contentDescription = if (tab == "threads") "Chats" else "Pull requests",
182 )
183 }
184}
apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chats.xml created+3
......@@ -0,0 +1,3 @@
1<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
2 <path android:fillColor="#000000" android:pathData="M20,2H4C2.9,2 2,2.9 2,4V22L6,18H20C21.1,18 22,17.1 22,16V4C22,2.9 21.1,2 20,2ZM20,16H5.17L4,17.17V4H20V16ZM6,6H18V8H6V6ZM6,10H16V12H6V10Z" />
3</vector>
apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_pull_requests.xml created+3
......@@ -0,0 +1,3 @@
1<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
2 <path android:fillColor="#000000" android:pathData="M6,2C4.34,2 3,3.34 3,5C3,6.31 3.84,7.42 5,7.83V16.17C3.84,16.58 3,17.69 3,19C3,20.66 4.34,22 6,22C7.66,22 9,20.66 9,19C9,17.69 8.16,16.58 7,16.17V7.83C8.16,7.42 9,6.31 9,5C9,3.34 7.66,2 6,2ZM6,4C6.55,4 7,4.45 7,5C7,5.55 6.55,6 6,6C5.45,6 5,5.55 5,5C5,4.45 5.45,4 6,4ZM6,18C6.55,18 7,18.45 7,19C7,19.55 6.55,20 6,20C5.45,20 5,19.55 5,19C5,18.45 5.45,18 6,18ZM14,2L10,6L14,10V7H15C16.1,7 17,7.9 17,9V16.17C15.84,16.58 15,17.69 15,19C15,20.66 16.34,22 18,22C19.66,22 21,20.66 21,19C21,17.69 20.16,16.58 19,16.17V9C19,6.79 17.21,5 15,5H14V2ZM18,18C18.55,18 19,18.45 19,19C19,19.55 18.55,20 18,20C17.45,20 17,19.55 17,19C17,18.45 17.45,18 18,18Z" />
3</vector>
apps/mobile/modules/t3-native-controls/expo-module.config.json+1
......@@ -7,6 +7,7 @@
77 "android": {
88 "modules": [
99 "expo.modules.t3nativecontrols.T3NativeControlsModule",
10 "expo.modules.t3nativecontrols.T3WorkspaceNavigationModule",
1011 "expo.modules.t3nativecontrols.T3KeyboardCommandsModule"
1112 ]
1213 }
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt+3
......@@ -41,6 +41,9 @@ class T3ReviewDiffModule : Module() {
4141 Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double ->
4242 view.setContentWidth(contentWidth.toFloat())
4343 }
44 Prop("contentInsetBottom") { view: T3ReviewDiffView, value: Double ->
45 view.setContentInsetBottom(value.toFloat())
46 }
4447 Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean ->
4548 view.setScrollEnabled(enabled)
4649 }
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt+12-4
......@@ -143,6 +143,12 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
143143 canvasView.contentWidthPx = max(width, dp(value).toInt())
144144 }
145145
146 fun setContentInsetBottom(value: Float) {
147 canvasView.setPadding(0, 0, 0, max(0, dp(value).toInt()))
148 canvasView.setVerticalOffset(canvasView.verticalOffset())
149 canvasView.invalidate()
150 }
151
146152 fun setInitialRowIndex(value: Double) {
147153 initialRowIndex = value.toInt().coerceAtLeast(0)
148154 pendingInitialScroll = true
......@@ -818,7 +824,7 @@ private class DiffCanvasView(context: Context) : View(context) {
818824 fun rowTop(index: Int): Int = rowOffsets[index.coerceIn(0, max(0, rowOffsets.size - 2))]
819825
820826 fun setVerticalOffset(value: Int) {
821 val maxOffset = max(0, (rowOffsets.lastOrNull() ?: 0) - height)
827 val maxOffset = maxVerticalOffset()
822828 val nextOffset = value.coerceIn(0, maxOffset)
823829 if (verticalOffset == nextOffset) return
824830 verticalOffset = nextOffset
......@@ -832,7 +838,8 @@ private class DiffCanvasView(context: Context) : View(context) {
832838
833839 fun verticalOffset(): Int = verticalOffset
834840
835 fun maxVerticalOffset(): Int = max(0, (rowOffsets.lastOrNull() ?: 0) - height)
841 fun maxVerticalOffset(): Int =
842 max(0, (rowOffsets.lastOrNull() ?: 0) + paddingBottom - height)
836843
837844 fun setHorizontalOffset(value: Int) {
838845 val nextOffset = value.coerceIn(0, maxHorizontalOffset())
......@@ -932,8 +939,9 @@ private class DiffCanvasView(context: Context) : View(context) {
932939 return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) }
933940 }
934941 }
935 val index = rowIndexAt(verticalOffset + y.toInt())
936 return rows.getOrNull(index)?.let {
942 val contentY = verticalOffset + y.toInt()
943 val index = rowIndexAt(contentY)
944 return rows.getOrNull(index)?.takeIf { contentY in 0 until rowOffsets.last() }?.let {
937945 RowHit(
938946 row = it,
939947 top = rowOffsets[index] - verticalOffset,
apps/mobile/src/Stack.tsx+21-12
......@@ -31,6 +31,7 @@ import { useResolveClassNames } from "uniwind";
3131import { AppText as Text } from "./components/AppText";
3232import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
3333import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen";
34import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider";
3435import {
3536 PullRequestRouteScreen,
3637 PullRequestFileRouteScreen,
......@@ -170,6 +171,12 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
170171 unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined,
171172};
172173
174// Android PR screens render their own Material toolbar.
175const PULL_REQUEST_HEADER_OPTIONS: AppScreenOptions = {
176 ...GLASS_HEADER_OPTIONS,
177 headerShown: Platform.OS !== "android",
178};
179
173180// SOLID: opaque sheet-colored header for surfaces that manage their own viewport.
174181const SOLID_HEADER_OPTIONS: AppScreenOptions = {
175182 headerBackButtonDisplayMode: "minimal",
......@@ -647,13 +654,15 @@ function RootStackLayout(props: {
647654 <ThreadOutboxDrainWorker />
648655 <ShowcaseCaptureCoordinator pathname={pathname} />
649656 <ExistingThreadSettingsRouteProvider>
650 <AdaptiveWorkspaceLayout
651 pathname={workspaceLocation.pathname}
652 workspaceRouteKey={workspaceLocation.routeKey}
653 >
654 {props.children}
655 <HardwareKeyboardCommandOverlay />
656 </AdaptiveWorkspaceLayout>
657 <AndroidWorkspaceTabsProvider>
658 <AdaptiveWorkspaceLayout
659 pathname={workspaceLocation.pathname}
660 workspaceRouteKey={workspaceLocation.routeKey}
661 >
662 {props.children}
663 <HardwareKeyboardCommandOverlay />
664 </AdaptiveWorkspaceLayout>
665 </AndroidWorkspaceTabsProvider>
657666 </ExistingThreadSettingsRouteProvider>
658667 </HardwareKeyboardCommandProvider>
659668 );
......@@ -721,15 +730,15 @@ const RootStackConfig = createWorkspaceStackNavigator({
721730 path: "pull-requests/:environmentId/:projectId/:repository/:number",
722731 parse: { number: Number },
723732 },
724 options: GLASS_HEADER_OPTIONS,
733 options: PULL_REQUEST_HEADER_OPTIONS,
725734 }),
726735 PullRequestGuideStop: createNativeStackScreen({
727736 screen: PullRequestGuideStopScreen,
728 options: GLASS_HEADER_OPTIONS,
737 options: PULL_REQUEST_HEADER_OPTIONS,
729738 }),
730739 PullRequestFile: createNativeStackScreen({
731740 screen: PullRequestFileRouteScreen,
732 options: GLASS_HEADER_OPTIONS,
741 options: PULL_REQUEST_HEADER_OPTIONS,
733742 }),
734743 PullRequestFiles: createNativeStackScreen({
735744 screen: PullRequestFilesRouteScreen,
......@@ -743,7 +752,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
743752 PullRequestReviewers: createNativeStackScreen({
744753 screen: PullRequestReviewersScreen,
745754 options: {
746 ...GLASS_HEADER_OPTIONS,
755 ...PULL_REQUEST_HEADER_OPTIONS,
747756 ...FORM_SHEET_PRESENTATION_OPTIONS,
748757 sheetAllowedDetents: [0.75, 0.92],
749758 sheetGrabberVisible: true,
......@@ -752,7 +761,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
752761 }),
753762 PullRequestDiscussion: createNativeStackScreen({
754763 screen: PullRequestDiscussionScreen,
755 options: GLASS_HEADER_OPTIONS,
764 options: PULL_REQUEST_HEADER_OPTIONS,
756765 }),
757766 PullRequestInlineComment: createNativeStackScreen({
758767 screen: PullRequestInlineCommentScreen,
apps/mobile/src/components/AppSymbol.ios.tsx+6-4
......@@ -5,6 +5,7 @@ import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequ
55import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft";
66import type { SFSymbol } from "expo-symbols";
77import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest";
8import IconFileDiff from "@tabler/icons-react-native/IconFileDiff";
89import { SymbolView as ExpoSymbolView } from "expo-symbols";
910import { withUniwind } from "uniwind";
1011import type { AppSymbolViewProps } from "./AppSymbol";
......@@ -12,7 +13,8 @@ import type { AppSymbolViewProps } from "./AppSymbol";
1213export type { SFSymbol } from "expo-symbols";
1314export type { AppSymbolName } from "./AppSymbol";
1415
15const GIT_ICONS = {
16const CUSTOM_ICONS = {
17 "file.diff": IconFileDiff,
1618 "arrow.triangle.pull": IconGitPullRequest,
1719 "arrow.triangle.merge": IconGitMerge,
1820 "arrow.triangle.branch": IconGitBranch,
......@@ -24,10 +26,10 @@ const GIT_ICONS = {
2426
2527function AppSymbolView(props: AppSymbolViewProps) {
2628 const name = typeof props.name === "string" ? props.name : props.name.ios;
27 const GitIcon = GIT_ICONS[name as keyof typeof GIT_ICONS];
28 if (GitIcon) {
29 const CustomIcon = CUSTOM_ICONS[name as keyof typeof CUSTOM_ICONS];
30 if (CustomIcon) {
2931 return (
30 <GitIcon
32 <CustomIcon
3133 accessibilityLabel={props.accessibilityLabel}
3234 color={props.tintColor}
3335 size={props.size}
apps/mobile/src/components/AppSymbol.tsx+10-2
......@@ -58,6 +58,7 @@ import IconExternalLink from "@tabler/icons-react-native/IconExternalLink";
5858import IconEyeOff from "@tabler/icons-react-native/IconEyeOff";
5959import IconEye from "@tabler/icons-react-native/IconEye";
6060import IconFileText from "@tabler/icons-react-native/IconFileText";
61import IconFileDiff from "@tabler/icons-react-native/IconFileDiff";
6162import IconFilter from "@tabler/icons-react-native/IconFilter";
6263import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled";
6364import IconFolder from "@tabler/icons-react-native/IconFolder";
......@@ -188,6 +189,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
188189 doc: IconFileText,
189190 "doc.on.doc": IconCopy,
190191 "doc.text": IconFileText,
192 "file.diff": IconFileDiff,
191193 ellipsis: IconDots,
192194 moon: IconMoon,
193195 "ellipsis.circle": IconDotsCircleHorizontal,
......@@ -271,10 +273,16 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
271273 xmark: IconX,
272274 "xmark.circle.fill": IconCircleXFilled,
273275} satisfies Partial<
274 Record<SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit", Icon>
276 Record<
277 SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff",
278 Icon
279 >
275280>;
276281const SF_ICON_LOOKUP: Partial<
277 Record<SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit", Icon>
282 Record<
283 SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff",
284 Icon
285 >
278286> = ANDROID_ICON_BY_SF_SYMBOL;
279287
280288// Callers can pass `{ ios, android }` names where `android` is a Material
apps/mobile/src/features/home/HomeHeader.android.tsx-2
......@@ -92,8 +92,6 @@ export function HomeHeader(props: HomeHeaderProps) {
9292 [props],
9393 );
9494
95 if (props.usesWorkspaceToolbar) return null;
96
9795 return (
9896 <>
9997 <NativeStackScreenOptions options={{ headerShown: false }} />
apps/mobile/src/features/home/HomeRouteScreen.tsx+2-2
......@@ -209,7 +209,7 @@ export function HomeRouteScreen() {
209209
210210 return (
211211 <AndroidHomeFabLayout
212 disabled={workspace !== null}
212 disabled={workspace !== null && Platform.OS !== "android"}
213213 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
214214 >
215215 <>
......@@ -230,7 +230,7 @@ export function HomeRouteScreen() {
230230 params: { screen: "SettingsEnvironments" },
231231 }),
232232 }),
233 headerShown: true,
233 headerShown: Platform.OS !== "android",
234234 }}
235235 />
236236 )}
apps/mobile/src/features/home/HomeScreen.tsx+1-1
......@@ -983,7 +983,7 @@ export function HomeScreen(props: HomeScreenProps) {
983983 const v2ListHeader = (
984984 <>
985985 {listHeader}
986 {workspace ? (
986 {Platform.OS === "ios" && workspace ? (
987987 <ThreadListFilters
988988 environments={props.environments}
989989 projects={projectScopes.map((scope) => ({ key: scope.key, label: scope.title }))}
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx+20-3
......@@ -1,4 +1,6 @@
11import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react";
2import { useNavigation, useNavigationState } from "@react-navigation/native";
3import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
24import {
35 BackHandler,
46 Keyboard,
......@@ -35,6 +37,12 @@ export function MaterialThreadListToolbar(props: {
3537 readonly onRequestVisibility?: () => void;
3638}) {
3739 const insets = useSafeAreaInsets();
40 const navigation = useNavigation();
41 const workspace = useWorkspaceTabs();
42 const isTabActive = workspace?.isTabActive;
43 const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home");
44 const listFocused =
45 homeFocused && (workspace === null || workspace.state.selectedTab === "threads");
3846 const { fabSize } = useAndroidControlSizing();
3947 const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout();
4048 const { state } = useWorkspaceState();
......@@ -57,13 +65,22 @@ export function MaterialThreadListToolbar(props: {
5765 }, [onSearchQueryChange]);
5866
5967 useEffect(() => {
60 if (!searching) return;
68 if (!searching || !listFocused) return;
6169 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
62 closeSearch();
70 // Frozen Home screens can keep effects alive after a detail route opens.
71 const state = navigation.getState();
72 if (
73 !state ||
74 state.routes[state.index]?.name !== "Home" ||
75 isTabActive?.("threads") === false
76 )
77 return false;
78 if (Keyboard.isVisible()) Keyboard.dismiss();
79 else closeSearch();
6380 return true;
6481 });
6582 return () => subscription.remove();
66 }, [closeSearch, searching]);
83 }, [closeSearch, searching, listFocused, navigation, isTabActive]);
6784
6885 const filterIcon = props.filterCustomized
6986 ? "line.3.horizontal.decrease.circle.fill"
apps/mobile/src/features/home/home-thread-navigation.test.ts+2
......@@ -19,6 +19,8 @@ function loadRouters() {
1919 };
2020}
2121
22vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Keyboard: { dismiss: vi.fn() } }));
23
2224vi.mock("@react-navigation/native", () => {
2325 const { CommonActions, StackActions } = loadRouters();
2426 return { CommonActions, StackActions };
apps/mobile/src/features/home/home-thread-navigation.ts+11-1
......@@ -7,6 +7,8 @@ import {
77} from "@react-navigation/native";
88import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
99import { useCallback, useEffect, useRef } from "react";
10import { Platform } from "react-native";
11import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
1012
1113type ThreadSelection = Pick<EnvironmentThreadShell, "environmentId" | "id">;
1214
......@@ -34,6 +36,7 @@ export function useHomeThreadSelection() {
3436 const navigation =
3537 useNavigation<NativeStackNavigationProp<ReactNavigation.RootParamList, "Home">>();
3638 const dismissingRouteKey = useRef<string | null>(null);
39 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
3740
3841 useEffect(() => {
3942 const clear = () => {
......@@ -58,6 +61,13 @@ export function useHomeThreadSelection() {
5861
5962 return useCallback(
6063 (thread: ThreadSelection) => {
64 if (Platform.OS === "android") {
65 selectAndroidWorkspace({
66 name: "Thread",
67 params: { environmentId: String(thread.environmentId), threadId: String(thread.id) },
68 });
69 return;
70 }
6171 navigation.dispatch((state) =>
6272 createHomeThreadNavigationAction({
6373 state,
......@@ -66,6 +76,6 @@ export function useHomeThreadSelection() {
6676 }),
6777 );
6878 },
69 [navigation],
79 [navigation, selectAndroidWorkspace],
7080 );
7181}
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+133-29
......@@ -24,6 +24,7 @@ import {
2424} from "react";
2525import { Platform, useWindowDimensions, View } from "react-native";
2626import Animated, {
27 runOnJS,
2728 useAnimatedStyle,
2829 useDerivedValue,
2930 useSharedValue,
......@@ -55,6 +56,13 @@ import {
5556 useHardwareKeyboardCommand,
5657} from "../keyboard/hardwareKeyboardCommands";
5758import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";
59import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
60import {
61 AndroidWorkspaceNavigation,
62 ANDROID_WORKSPACE_RAIL_WIDTH,
63} from "../workspace/AndroidWorkspaceNavigation";
64import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar";
65import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
5866import { HomeListOptionsProvider } from "../home/home-list-options";
5967import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
6068import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
......@@ -243,7 +251,8 @@ function AdaptiveWorkspaceLayoutContent(
243251 const nativeWorkspace = use(NativeWorkspaceModeContext);
244252 const windowDimensions = useWindowDimensions();
245253 const nativeMetrics = useNativeLayoutMetrics();
246 const { width, height } = nativeMetrics ?? windowDimensions;
254 const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions;
255 const { scale: androidControlScale } = useAndroidControlSizing();
247256 const pathname = props.pathname;
248257 const navigation = useNavigation();
249258 const activeRoleOwner = useRef<symbol | null>(null);
......@@ -261,10 +270,18 @@ function AdaptiveWorkspaceLayoutContent(
261270 const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] =
262271 useState<WorkspaceAuxiliaryPaneRole | null>(null);
263272 const baseLayout = useMemo(
264 () => deriveLayout({ width, height, nativeMetrics }),
265 [height, width, nativeMetrics],
273 () => deriveLayout({ width: viewportWidth, height, nativeMetrics }),
274 [height, viewportWidth, nativeMetrics],
266275 );
267276 const layout = baseLayout;
277 // The rail consumes real space before either pane; inspectors and the detail
278 // navigator must use the remaining viewport rather than clipping behind it.
279 const shouldRenderNavigationRail = Platform.OS === "android" && layout.usesSplitView;
280 const navigationRailWidth =
281 shouldRenderNavigationRail && showPrimarySidebar
282 ? ANDROID_WORKSPACE_RAIL_WIDTH * Math.max(1, androidControlScale)
283 : 0;
284 const width = viewportWidth - navigationRailWidth;
268285 // In split layouts the sidebar IS the thread list — it renders on every
269286 // route, including Home (which shows an empty-detail pane instead of the
270287 // compact list).
......@@ -478,22 +495,80 @@ function AdaptiveWorkspaceLayoutContent(
478495 [navigation],
479496 );
480497
498 const inspectorColumnTargetWidth =
499 workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible
500 ? (panes.auxiliaryPaneWidth ?? 0)
501 : 0;
502 const contentTargetWidth = layout.usesSplitView
503 ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth)
504 : null;
505 const [contentSizing, setContentSizing] = useState({
506 viewportWidth,
507 target: contentTargetWidth,
508 sidebarVisible: panes.primarySidebarVisible,
509 width: contentTargetWidth,
510 revision: 0,
511 });
512 if (
513 Platform.OS === "android" &&
514 (contentSizing.viewportWidth !== viewportWidth ||
515 contentSizing.target !== contentTargetWidth ||
516 contentSizing.sidebarVisible !== panes.primarySidebarVisible)
517 ) {
518 const openingSidebar =
519 Platform.OS === "android" &&
520 contentSizing.viewportWidth === viewportWidth &&
521 !contentSizing.sidebarVisible &&
522 panes.primarySidebarVisible &&
523 contentSizing.width !== null &&
524 contentTargetWidth !== null;
525 setContentSizing({
526 viewportWidth,
527 target: contentTargetWidth,
528 sidebarVisible: panes.primarySidebarVisible,
529 // Clip the wide navigator during expansion, then reflow once at the end.
530 // Narrowing it first exposes an empty strip before the sidebar arrives.
531 width: openingSidebar
532 ? Math.max(contentSizing.width ?? 0, contentTargetWidth ?? 0)
533 : contentTargetWidth,
534 revision: contentSizing.revision + 1,
535 });
536 }
537 const settleContentWidth = useCallback((revision: number) => {
538 setContentSizing((current) =>
539 current.revision !== revision || current.width === current.target
540 ? current
541 : { ...current, width: current.target },
542 );
543 }, []);
544 const contentSettledWidth = Platform.OS === "android" ? contentSizing.width : contentTargetWidth;
545 const contentRevision = contentSizing.revision;
546
481547 const renderedSidebarWidth = useSharedValue(
482548 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0,
483549 );
550 const renderedRailWidth = useSharedValue(navigationRailWidth);
484551 useEffect(() => {
485552 if (nativeWorkspace) return;
486553 const targetWidth = panes.primarySidebarVisible
487554 ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0)
488555 : 0;
489 renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING);
556 renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING, (finished) => {
557 if (finished && Platform.OS === "android") runOnJS(settleContentWidth)(contentRevision);
558 });
559 renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING));
490560 }, [
491561 nativeWorkspace,
562 navigationRailWidth,
492563 layout.listPaneWidth,
493564 layout.listPaneGap,
494565 panes.primarySidebarVisible,
495566 renderedSidebarWidth,
567 renderedRailWidth,
568 settleContentWidth,
569 contentRevision,
496570 ]);
571 const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value }));
497572 const sidebarAnimatedStyle = useAnimatedStyle(() => ({
498573 // Fading a UIKit glass ancestor can leave remounted header effects missing.
499574 // The clipped width already reveals and hides the iOS sidebar.
......@@ -507,24 +582,30 @@ function AdaptiveWorkspaceLayoutContent(
507582 // animating columns merely clip/reveal it over a matching background.
508583 // Continuously re-wrapping the chat feed was the main source of dropped
509584 // frames during sidebar/inspector transitions.
510 const inspectorColumnTargetWidth =
511 workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible
512 ? (panes.auxiliaryPaneWidth ?? 0)
513 : 0;
514 const contentSettledWidth = layout.usesSplitView
515 ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth)
516 : null;
517585 const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth);
518586 const renderedContentWidth = useDerivedValue(() =>
519 Math.max(0, width - renderedSidebarWidth.value - renderedInspectorWidth.value),
587 Math.max(
588 0,
589 viewportWidth -
590 renderedRailWidth.value -
591 renderedSidebarWidth.value -
592 renderedInspectorWidth.value,
593 ),
520594 );
521595
596 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
522597 const handleSelectThread = useCallback(
523598 (thread: EnvironmentThreadShell) => {
524599 const params = {
525600 environmentId: String(thread.environmentId),
526601 threadId: String(thread.id),
527602 };
603 if (Platform.OS === "android") {
604 // Selecting a sidebar row enters its base chat, including from that chat's nested screens.
605 setFileInspectorPreferredVisible(false);
606 selectAndroidWorkspace({ name: "Thread", params });
607 return;
608 }
528609 const navigationAction = resolveThreadSelectionNavigationAction({
529610 usesSplitView: layout.usesSplitView,
530611 pathname,
......@@ -556,7 +637,14 @@ function AdaptiveWorkspaceLayoutContent(
556637 }
557638 navigation.navigate("Thread", params);
558639 },
559 [layout.usesSplitView, pathname, navigation, selectedThreadKey, props.workspaceRouteKey],
640 [
641 layout.usesSplitView,
642 pathname,
643 navigation,
644 selectedThreadKey,
645 props.workspaceRouteKey,
646 selectAndroidWorkspace,
647 ],
560648 );
561649
562650 const contextValue = useMemo(
......@@ -606,7 +694,21 @@ function AdaptiveWorkspaceLayoutContent(
606694 return (
607695 <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}>
608696 <AdaptiveWorkspaceContext.Provider value={contextValue}>
609 <View testID="adaptive-workspace-layout" className="flex-1 flex-row">
697 <View
698 testID="adaptive-workspace-layout"
699 className={Platform.OS === "android" ? "flex-1 flex-row bg-header" : "flex-1 flex-row"}
700 >
701 {shouldRenderNavigationRail ? (
702 <Animated.View
703 className="self-stretch overflow-hidden"
704 accessibilityElementsHidden={navigationRailWidth === 0}
705 importantForAccessibility={navigationRailWidth > 0 ? "auto" : "no-hide-descendants"}
706 pointerEvents={navigationRailWidth > 0 ? "auto" : "none"}
707 style={railAnimatedStyle}
708 >
709 <AndroidWorkspaceNavigation rail onSelect={revealPrimarySidebar} />
710 </Animated.View>
711 ) : null}
610712 {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? (
611713 <Animated.View
612714 className="self-stretch overflow-hidden"
......@@ -628,21 +730,23 @@ function AdaptiveWorkspaceLayoutContent(
628730 />
629731 )}
630732 >
631 <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>
632 <ThreadNavigationSidebar
633 width={layout.listPaneWidth}
634 visible={panes.primarySidebarVisible}
635 onRequestVisibility={revealPrimarySidebar}
636 selectedThreadKey={selectedThreadKey}
637 onOpenSettings={handleOpenSettings}
638 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
639 onNewThreadInProject={handleNewThreadInProject}
640 onNewThreadOnBranch={handleNewThreadOnBranch}
641 onSelectThread={handleSelectThread}
642 onSearchQueryChange={setPrimarySidebarSearchQuery}
643 searchQuery={primarySidebarSearchQuery}
644 />
645 </AndroidHomeFabLayout>
733 <AndroidWorkspaceSidebar onRequestVisibility={revealPrimarySidebar}>
734 <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>
735 <ThreadNavigationSidebar
736 width={layout.listPaneWidth}
737 visible={panes.primarySidebarVisible}
738 onRequestVisibility={revealPrimarySidebar}
739 selectedThreadKey={selectedThreadKey}
740 onOpenSettings={handleOpenSettings}
741 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
742 onNewThreadInProject={handleNewThreadInProject}
743 onNewThreadOnBranch={handleNewThreadOnBranch}
744 onSelectThread={handleSelectThread}
745 onSearchQueryChange={setPrimarySidebarSearchQuery}
746 searchQuery={primarySidebarSearchQuery}
747 />
748 </AndroidHomeFabLayout>
749 </AndroidWorkspaceSidebar>
646750 </RenderErrorBoundary>
647751 </View>
648752 </Animated.View>
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+1-1
......@@ -10,7 +10,7 @@ export function AndroidWorkspaceSidebarButton() {
1010
1111 return (
1212 <AndroidHeaderIconButton
13 accessibilityLabel={panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar"}
13 accessibilityLabel={panes.primarySidebarVisible ? "Maximize content" : "Show sidebar"}
1414 icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"}
1515 onPress={togglePrimarySidebar}
1616 />
apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx created+26
......@@ -0,0 +1,26 @@
1import { HorizontalFloatingToolbar, Host, RNHostView } from "@expo/ui/jetpack-compose";
2import type { ReactElement } from "react";
3
4import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
5
6/** Native toolbar surface; hosted RN controls retain their existing menus and accessibility. */
7export function MaterialFloatingToolbar(props: { readonly children: ReactElement }) {
8 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
9 return (
10 <Host
11 matchContents
12 colorScheme={themeAppearance}
13 ignoreSafeAreaKeyboardInsets
14 style={{ alignSelf: "center", maxWidth: "100%" }}
15 >
16 <HorizontalFloatingToolbar
17 colors={{
18 toolbarContainerColor: colors["--color-card-alt"],
19 toolbarContentColor: colors["--color-foreground"],
20 }}
21 >
22 <RNHostView matchContents>{props.children}</RNHostView>
23 </HorizontalFloatingToolbar>
24 </Host>
25 );
26}
apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.tsx created+5
......@@ -0,0 +1,5 @@
1import type { ReactElement } from "react";
2
3export function MaterialFloatingToolbar(props: { readonly children: ReactElement }) {
4 return props.children;
5}
apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx created+116
......@@ -0,0 +1,116 @@
1import { useNavigation, useNavigationState } from "@react-navigation/native";
2import { useCallback, useEffect, useRef } from "react";
3import { BackHandler, Keyboard, View, type TextInputInstance } from "react-native";
4import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
5import { CompactBrandTitle } from "../../components/CompactBrandTitle";
6import { MaterialSearchField } from "../../components/MaterialSearchField";
7import { WorkspaceConnectionTitle } from "../home/WorkspaceConnectionTitle";
8import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
9import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
10
11/** PR search shares the thread list's native Android header placement. */
12export function MaterialPullRequestToolbar(props: { readonly onRequestVisibility?: () => void }) {
13 const navigation = useNavigation();
14 const workspace = useWorkspaceTabs()!;
15 const { dispatch, getScrollOffset, recordScrollOffset, isTabActive } = workspace;
16 const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home");
17 const listFocused = homeFocused && workspace.state.selectedTab === "pullRequests";
18 const { onRequestVisibility } = props;
19 const tab = workspace.state.tabs.pullRequests;
20 const input = useRef<TextInputInstance>(null);
21 const searching = tab.searchMode !== "resting";
22 const openSearch = useCallback(() => {
23 onRequestVisibility?.();
24 dispatch({
25 type: "scroll",
26 tab: "pullRequests",
27 offset: getScrollOffset("pullRequests"),
28 });
29 dispatch({ type: "search", tab: "pullRequests" });
30 input.current?.focus();
31 return true;
32 }, [dispatch, getScrollOffset, onRequestVisibility]);
33 useHardwareKeyboardCommand("focusSearch", openSearch);
34 const closeSearch = useCallback(() => {
35 Keyboard.dismiss();
36 recordScrollOffset("pullRequests", tab.searchEntryOffset);
37 dispatch({ type: "cancel", tab: "pullRequests" });
38 }, [dispatch, recordScrollOffset, tab.searchEntryOffset]);
39 useEffect(() => {
40 if (!searching || !listFocused) return;
41 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
42 // Frozen Home screens can keep effects alive after a detail route opens.
43 const state = navigation.getState();
44 if (!state || state.routes[state.index]?.name !== "Home" || !isTabActive("pullRequests"))
45 return false;
46 if (Keyboard.isVisible()) Keyboard.dismiss();
47 else closeSearch();
48 return true;
49 });
50 return () => subscription.remove();
51 }, [searching, closeSearch, listFocused, navigation, isTabActive]);
52 return (
53 <AndroidScreenHeader
54 title="Pull requests"
55 titleContent={
56 searching ? (
57 <MaterialSearchField
58 inputRef={input}
59 accessibilityLabel="Search pull requests"
60 clearAccessibilityLabel="Clear search"
61 placeholder="Search pull requests"
62 value={tab.query}
63 onChangeText={(query) => dispatch({ type: "query", tab: "pullRequests", query })}
64 />
65 ) : (
66 <View className="min-w-0 flex-1 pl-3">
67 <WorkspaceConnectionTitle
68 grow
69 brand={
70 <CompactBrandTitle
71 allowFontScaling={false}
72 accessibilityLabel="T3 Code, Pull requests"
73 />
74 }
75 onPress={() =>
76 navigation.navigate("SettingsSheet", {
77 screen: "SettingsContent",
78 params: { screen: "SettingsEnvironments" },
79 })
80 }
81 />
82 </View>
83 )
84 }
85 leading={
86 searching ? (
87 <AndroidHeaderIconButton
88 accessibilityLabel="Close search"
89 icon="arrow.left"
90 onPress={closeSearch}
91 />
92 ) : undefined
93 }
94 actions={
95 searching
96 ? []
97 : [
98 {
99 accessibilityLabel: "Search pull requests",
100 icon: "magnifyingglass",
101 onPress: openSearch,
102 },
103 {
104 accessibilityLabel: "Open settings",
105 icon: "gearshape",
106 onPress: () =>
107 navigation.navigate("SettingsSheet", {
108 screen: "SettingsContent",
109 params: { screen: "Settings" },
110 }),
111 },
112 ]
113 }
114 />
115 );
116}
apps/mobile/src/features/pull-requests/PullRequestControls.tsx+43-35
......@@ -9,6 +9,7 @@ import { AppText as Text } from "../../components/AppText";
99import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
1010import { MaterialButton } from "../../components/MaterialButton";
1111import { MaterialIconButton } from "../../components/MaterialIconButton";
12import { MaterialFloatingToolbar } from "./MaterialFloatingToolbar";
1213import { ControlPillMenu } from "../../components/ControlPillMenu";
1314import { NativeHeaderToolbar } from "../../native/StackHeader";
1415import {
......@@ -110,6 +111,7 @@ export function PullRequestActionToolbar(props: {
110111 readonly params: PullRequestParams;
111112 readonly filePath?: string;
112113 readonly onOpenFiles?: () => void;
114 readonly onHeightChange?: (height: number) => void;
113115}) {
114116 const navigation = useNavigation();
115117 const { draft } = usePullRequestReviewDraft(props.params);
......@@ -156,52 +158,57 @@ export function PullRequestActionToolbar(props: {
156158 const primary = (
157159 <MaterialButton
158160 label={primaryLabel}
159 tone="contrast"
160 fullWidth
161 tone="primary"
161162 disabled={primaryDisabled}
162163 onPress={primaryPress}
163164 />
164165 );
165166 return (
166167 <View
167 className="flex-row items-center justify-between gap-3 border-t border-border-subtle bg-screen px-4 pt-2"
168 style={{ paddingBottom: Math.max(insets.bottom, 8) }}
168 pointerEvents="box-none"
169 className="absolute inset-x-0 bottom-0 z-10 items-center px-4 pt-2"
170 style={{ paddingBottom: insets.bottom + 16 }}
171 onLayout={(event) => props.onHeightChange?.(event.nativeEvent.layout.height)}
169172 >
170 {showAgentControls ? (
171 <ControlPillMenu
172 title="AI helpers"
173 actions={agentActions.map((action) => agent.menuAction(action.task))}
174 onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)}
175 >
176 <View pointerEvents="none">
177 <MaterialIconButton
178 icon="sparkles"
179 accessibilityLabel="PR agent actions"
180 disabled={agent.disabled}
181 />
182 </View>
183 </ControlPillMenu>
184 ) : null}
185 {showPrimary ? (
186 <View className="min-w-0 flex-1">
187 {!pendingReview && chooseThread ? (
173 <MaterialFloatingToolbar>
174 <View className="flex-row items-center gap-2">
175 {showAgentControls ? (
188176 <ControlPillMenu
189 title="Open thread"
190 actions={threadMenuItems}
177 title="AI helpers"
178 actions={agentActions.map((action) => agent.menuAction(action.task))}
191179 onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)}
192180 >
193 <View pointerEvents="none">{primary}</View>
181 <View pointerEvents="none">
182 <MaterialIconButton
183 icon="sparkles"
184 accessibilityLabel="PR agent actions"
185 disabled={agent.disabled}
186 />
187 </View>
194188 </ControlPillMenu>
195 ) : (
196 primary
197 )}
189 ) : null}
190 {showPrimary ? (
191 <View className="min-w-0 shrink">
192 {!pendingReview && chooseThread ? (
193 <ControlPillMenu
194 title="Open thread"
195 actions={threadMenuItems}
196 onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)}
197 >
198 <View pointerEvents="none">{primary}</View>
199 </ControlPillMenu>
200 ) : (
201 primary
202 )}
203 </View>
204 ) : null}
205 <MaterialIconButton
206 icon="file.diff"
207 accessibilityLabel="Changed files"
208 onPress={openFiles}
209 />
198210 </View>
199 ) : null}
200 <MaterialIconButton
201 icon="doc.on.doc"
202 accessibilityLabel="Changed files"
203 onPress={openFiles}
204 />
211 </MaterialFloatingToolbar>
205212 </View>
206213 );
207214 }
......@@ -299,12 +306,13 @@ export function PullRequestActionToolbar(props: {
299306 )}
300307 </NativeHeaderToolbar.Menu>
301308 ) : null;
309 // Tabler file-diff geometry, rasterized at 1x/2x/3x for UIKit template images.
302310 const filesControl = (
303311 <NativeHeaderToolbar.Button
304312 identifier="workspace-trailing-action"
305313 width={controlSize}
306314 height={controlSize}
307 icon="document.on.document"
315 icon={{ type: "image", source: require("../../../assets/icons/file-diff.png") }}
308316 accessibilityLabel="Changed files"
309317 onPress={openFiles}
310318 separateBackground
apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx+65-36
......@@ -3,10 +3,12 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"
33import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts";
44import { AsyncResult } from "effect/reactivity";
55import { useRef, useState } from "react";
6import { ActivityIndicator, Alert, Pressable, View } from "react-native";
6import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
77import { AppText as Text } from "../../components/AppText";
88import { SymbolView } from "../../components/AppSymbol";
99import { ControlPillMenu } from "../../components/ControlPillMenu";
10import { MaterialButton } from "../../components/MaterialButton";
11import { MaterialIconButton } from "../../components/MaterialIconButton";
1012import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
1113import { composerPullRequests } from "../../state/pull-requests";
1214import { useAtomCommand } from "../../state/use-atom-command";
......@@ -164,33 +166,50 @@ export function PullRequestMergeControls(props: {
164166 return (
165167 <View className="gap-2 border-t border-border-subtle p-3">
166168 <View
167 className={`flex-row overflow-hidden rounded-xl border-continuous ${canMerge ? "bg-foreground" : "bg-subtle-strong"}`}
169 className={
170 Platform.OS === "android"
171 ? "flex-row items-center gap-2"
172 : `flex-row overflow-hidden rounded-xl border-continuous ${canMerge ? "bg-foreground" : "bg-subtle-strong"}`
173 }
168174 >
169 <Pressable
170 accessibilityRole="button"
171 accessibilityLabel={
172 scope ? `${label}, ${scope.layers.length} stack layers through #${pr.number}` : label
173 }
174 accessibilityState={{ disabled: !canMerge, busy: busy || checkingStack }}
175 disabled={!canMerge}
176 onPress={() => confirm("merge")}
177 className="min-h-11 min-w-0 flex-1 flex-row items-center justify-center gap-2 px-3 py-2 active:opacity-70"
178 >
179 {busy || checkingStack ? (
180 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
181 ) : (
182 <SymbolView
183 name="arrow.triangle.merge"
184 size={16}
185 tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"}
175 {Platform.OS === "android" ? (
176 <View className="min-w-0 flex-1">
177 <MaterialButton
178 label={busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label}
179 tone="primary"
180 fullWidth
181 disabled={!canMerge}
182 loading={busy || checkingStack}
183 onPress={() => confirm("merge")}
186184 />
187 )}
188 <Text
189 className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-screen" : "text-foreground-muted"}`}
185 </View>
186 ) : (
187 <Pressable
188 accessibilityRole="button"
189 accessibilityLabel={
190 scope ? `${label}, ${scope.layers.length} stack layers through #${pr.number}` : label
191 }
192 accessibilityState={{ disabled: !canMerge, busy: busy || checkingStack }}
193 disabled={!canMerge}
194 onPress={() => confirm("merge")}
195 className="min-h-11 min-w-0 flex-1 flex-row items-center justify-center gap-2 px-3 py-2 active:opacity-70"
190196 >
191 {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label}
192 </Text>
193 </Pressable>
197 {busy || checkingStack ? (
198 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
199 ) : (
200 <SymbolView
201 name="arrow.triangle.merge"
202 size={16}
203 tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"}
204 />
205 )}
206 <Text
207 className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-screen" : "text-foreground-muted"}`}
208 >
209 {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label}
210 </Text>
211 </Pressable>
212 )}
194213 {methods.length > 0 || canAutoMerge ? (
195214 <ControlPillMenu
196215 title="Merge options"
......@@ -219,17 +238,27 @@ export function PullRequestMergeControls(props: {
219238 } else if (nativeEvent.event === autoAction && canAutoMerge) confirm(autoAction);
220239 }}
221240 >
222 <Pressable
223 accessibilityRole="button"
224 accessibilityLabel="Merge options"
225 className="min-h-11 w-11 items-center justify-center border-l border-border-subtle"
226 >
227 <SymbolView
228 name="chevron.down"
229 size={13}
230 tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"}
231 />
232 </Pressable>
241 {Platform.OS === "android" ? (
242 <View pointerEvents="none">
243 <MaterialIconButton
244 accessibilityLabel="Merge options"
245 icon="chevron.down"
246 variant="tonal"
247 />
248 </View>
249 ) : (
250 <Pressable
251 accessibilityRole="button"
252 accessibilityLabel="Merge options"
253 className="min-h-11 w-11 items-center justify-center border-l border-border-subtle"
254 >
255 <SymbolView
256 name="chevron.down"
257 size={13}
258 tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"}
259 />
260 </Pressable>
261 )}
233262 </ControlPillMenu>
234263 ) : null}
235264 </View>
apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx+2-2
......@@ -4,7 +4,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"
44import type { PullRequestReviewVerdict } from "@t3tools/contracts";
55import { useCallback, useRef, useState } from "react";
66import { useFocusEffect } from "@react-navigation/native";
7import { Pressable, View } from "react-native";
7import { Platform, Pressable, View } from "react-native";
88import { clearSubmittedReview } from "./pullRequestReviewDrafts";
99import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft";
1010import { reviewPositionLabel } from "./pullRequestDiffComments";
......@@ -137,7 +137,7 @@ export function PullRequestReviewSheet({
137137 : "Post comment"
138138 }
139139 fullWidth
140 tone="contrast"
140 tone={Platform.OS === "android" ? "primary" : "contrast"}
141141 loading={submission.busy}
142142 disabled={!canSubmit || result.isPending || result.error !== null}
143143 onPress={() => void perform()}
apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx+48-9
......@@ -57,6 +57,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
5757
5858import { AppText as Text } from "../../components/AppText";
5959import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
60import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
61import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
62import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar";
63import { useAndroidWorkspaceBack } from "../workspace/useAndroidWorkspaceNavigation";
6064import { EmptyState } from "../../components/EmptyState";
6165import { SegmentedControl } from "../../components/SegmentedControl";
6266import { SymbolView } from "../../components/AppSymbol";
......@@ -269,9 +273,18 @@ function PullRequestOverview(props: {
269273 );
270274}
271275
276function usePullRequestToolbarHeight(bottomInset: number) {
277 const { buttonSize } = useAndroidControlSizing();
278 // Reserve the controls, native toolbar padding, and outer gaps before onLayout measures them.
279 return useState(() => (Platform.OS === "android" ? bottomInset + buttonSize + 40 : 0));
280}
281
272282export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestParams>) {
283 const returnToList = useAndroidWorkspaceBack("pullRequests");
284 const { layout } = useAdaptiveWorkspaceLayout();
273285 const navigation = useNavigation();
274286 const insets = useSafeAreaInsets();
287 const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
275288 const params = route.params;
276289 const connected = usePullRequestConnected(params.environmentId);
277290 const [section, setSection] = useState<"overview" | "activity" | "guide">("overview");
......@@ -356,7 +369,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
356369 params={params}
357370 value={pr?.provider === "github" ? pr.url : null}
358371 >
359 <View className="flex-1 bg-screen">
372 <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}>
360373 <NativeStackScreenOptions
361374 options={{
362375 title: `#${params.number}`,
......@@ -369,7 +382,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
369382 {Platform.OS === "android" ? (
370383 <AndroidScreenHeader
371384 title={`#${params.number}${connected ? "" : " · Offline"}`}
372 onBack={() => navigation.goBack()}
385 subtitle={params.repository}
386 onBack={layout.usesSplitView ? undefined : returnToList}
387 leading={layout.usesSplitView ? <AndroidWorkspaceSidebarButton /> : undefined}
373388 actions={[
374389 ...(permissions.verdicts.length
375390 ? [
......@@ -442,7 +457,15 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
442457 </NativeHeaderToolbar.Menu>
443458 </NativeHeaderToolbar>
444459 <View
445 style={{ flex: 1 }}
460 style={{
461 flex: 1,
462 marginHorizontal: Platform.OS === "android" && layout.usesSplitView ? 4 : 0,
463 }}
464 className={
465 Platform.OS === "android"
466 ? "overflow-hidden rounded-t-[28px] bg-sheet-solid"
467 : undefined
468 }
446469 onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)}
447470 >
448471 {(["overview", "guide", "activity"] as const)
......@@ -499,7 +522,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
499522 contentContainerStyle={{
500523 paddingHorizontal: 16,
501524 paddingTop: 8,
502 paddingBottom: 24,
525 paddingBottom: 24 + (Platform.OS === "android" ? toolbarHeight : 0),
503526 // Short tabs must still be tall enough to preserve the collapsed header.
504527 minHeight: Math.max(
505528 0,
......@@ -621,7 +644,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
621644 >
622645 <PullRequestIdentity pr={identity} params={params} />
623646 <SegmentedControl
624 className="bg-grouped-card"
647 className={Platform.OS === "android" ? undefined : "bg-grouped-card"}
625648 options={[
626649 { value: "overview", label: "Overview" },
627650 ...(guidesEnabled
......@@ -643,7 +666,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
643666 </View>
644667 ))}
645668 </View>
646 <PullRequestActionToolbar pr={pr} params={params} onOpenFiles={openFiles} />
669 <PullRequestActionToolbar
670 pr={pr}
671 params={params}
672 onOpenFiles={openFiles}
673 onHeightChange={setToolbarHeight}
674 />
647675 </View>
648676 </PullRequestMarkdownRepository>
649677 );
......@@ -685,8 +713,10 @@ export function PullRequestFileRouteScreen({
685713 readonly focus?: GuideDiffFocus;
686714 }
687715>) {
716 const { layout } = useAdaptiveWorkspaceLayout();
688717 const navigation = useNavigation();
689718 const insets = useSafeAreaInsets();
719 const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
690720 const commit = route.params.commit;
691721 const result = usePullRequestDiff(route.params);
692722 const filter = usePullRequestFileFilter(
......@@ -875,7 +905,7 @@ export function PullRequestFileRouteScreen({
875905 const seekingFile = !targetFile && result.nextCursor !== null;
876906 const loadingDiff = (result.isPending && !data.rows.length) || (seekingFile && !result.error);
877907 return (
878 <View className="flex-1 bg-screen">
908 <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}>
879909 <NativeStackScreenOptions
880910 options={{
881911 title,
......@@ -1022,7 +1052,15 @@ export function PullRequestFileRouteScreen({
10221052 />
10231053 </View>
10241054 ) : null}
1025 <View className="flex-1" collapsable={false}>
1055 <View
1056 className={
1057 Platform.OS === "android"
1058 ? "flex-1 overflow-hidden rounded-t-[28px] bg-sheet-solid"
1059 : "flex-1"
1060 }
1061 collapsable={false}
1062 style={{ marginHorizontal: Platform.OS === "android" && layout.usesSplitView ? 4 : 0 }}
1063 >
10261064 {loadingDiff || result.error ? (
10271065 <View style={{ paddingTop: Platform.OS === "ios" && !staleDiff ? insets.top : 0 }}>
10281066 <PullRequestReadStatus pending={loadingDiff} error={result.error} />
......@@ -1097,7 +1135,7 @@ export function PullRequestFileRouteScreen({
10971135 rowHeight={nativeReviewDiffStyle.rowHeight}
10981136 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
10991137 contentInsetTop={Platform.OS === "ios" ? (staleDiff ? 0 : insets.top) : undefined}
1100 contentInsetBottom={Platform.OS === "ios" ? insets.bottom : undefined}
1138 contentInsetBottom={Platform.OS === "android" ? toolbarHeight : insets.bottom}
11011139 contentResetKey={`pull-request:${route.params.environmentId}:${route.params.repository}:${route.params.number}:${commit ?? "all"}`}
11021140 />
11031141 </View>
......@@ -1147,6 +1185,7 @@ export function PullRequestFileRouteScreen({
11471185 params={route.params}
11481186 filePath={visibleFile?.path}
11491187 onOpenFiles={() => navigation.navigate("PullRequestFiles", route.params)}
1188 onHeightChange={setToolbarHeight}
11501189 />
11511190 </View>
11521191 );
apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx+91-35
......@@ -1,9 +1,12 @@
11import { useNavigation } from "@react-navigation/native";
22import type { ReactNode } from "react";
3import { ScrollView, View } from "react-native";
3import { Platform, ScrollView, useWindowDimensions, View } from "react-native";
44import { KeyboardAvoidingView } from "react-native-keyboard-controller";
55import { useSafeAreaInsets } from "react-native-safe-area-context";
66import { AppText as Text } from "../../components/AppText";
7import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
8import { MaterialIconButton } from "../../components/MaterialIconButton";
9import { MaterialScreenContent } from "../../components/MaterialScreenContent";
710import { ControlPill } from "../../components/ControlPill";
811import { NativeStackScreenOptions } from "../../native/StackHeader";
912
......@@ -17,44 +20,97 @@ export function PullRequestSheetContent(props: {
1720}) {
1821 const navigation = useNavigation();
1922 const insets = useSafeAreaInsets();
20 return (
21 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1 bg-sheet">
22 <NativeStackScreenOptions options={{ gestureEnabled: !props.busy }} />
23 <View className="flex-row items-center gap-2 px-4 pt-5 pb-5">
24 <ControlPill
25 icon="xmark"
26 accessibilityLabel="Close"
27 disabled={props.busy}
28 onPress={() => navigation.goBack()}
23 const { height: windowHeight } = useWindowDimensions();
24 const android = Platform.OS === "android";
25 const fitToContents = android && props.scrollable !== false;
26 const footer = props.footer ? (
27 <View
28 className={
29 android && props.scrollable !== false ? "bg-sheet-solid pt-3" : "bg-sheet px-4 pt-3"
30 }
31 style={{ paddingBottom: Math.max(insets.bottom, 16) }}
32 >
33 {android ? <View style={{ minHeight: 48 }}>{props.footer}</View> : props.footer}
34 </View>
35 ) : null;
36 const content = (
37 <>
38 {android ? (
39 <AndroidSheetHeader
40 title={props.title}
41 subtitle={props.subtitle}
42 hideBottomBorder
43 leading={
44 <MaterialIconButton
45 icon="arrow.left"
46 accessibilityLabel="Close"
47 disabled={props.busy}
48 tintColorClassName="accent-header-foreground"
49 onPress={() => navigation.goBack()}
50 />
51 }
2952 />
30 <View className="min-w-0 flex-1 items-center gap-1">
31 <Text className="text-center text-base font-t3-bold">{props.title}</Text>
32 <Text numberOfLines={1} className="text-center text-xs text-foreground-muted">
33 {props.subtitle}
34 </Text>
35 </View>
36 <View className="w-11" />
37 </View>
38 {props.scrollable === false ? (
39 <View className="flex-1" pointerEvents={props.busy ? "none" : "auto"}>
40 {props.children}
41 </View>
4253 ) : (
43 <ScrollView
44 className="flex-1"
45 keyboardShouldPersistTaps="handled"
46 contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 20 }}
47 >
48 <View className="gap-4" pointerEvents={props.busy ? "none" : "auto"}>
49 {props.children}
54 <View className="flex-row items-center gap-2 px-4 pt-5 pb-5">
55 <ControlPill
56 icon="xmark"
57 accessibilityLabel="Close"
58 disabled={props.busy}
59 onPress={() => navigation.goBack()}
60 />
61 <View className="min-w-0 flex-1 items-center gap-1">
62 <Text className="text-center text-base font-t3-bold">{props.title}</Text>
63 <Text numberOfLines={1} className="text-center text-xs text-foreground-muted">
64 {props.subtitle}
65 </Text>
5066 </View>
51 </ScrollView>
52 )}
53 {props.footer ? (
54 <View className="bg-sheet px-4 pt-3" style={{ paddingBottom: Math.max(insets.bottom, 16) }}>
55 {props.footer}
67 <View className="w-11" />
5668 </View>
57 ) : null}
69 )}
70 <MaterialScreenContent fitToContents={fitToContents}>
71 {props.scrollable === false ? (
72 <View className="flex-1" pointerEvents={props.busy ? "none" : "auto"}>
73 {props.children}
74 </View>
75 ) : (
76 <ScrollView
77 nestedScrollEnabled
78 className="android:shrink android:grow-0 ios:flex-1"
79 keyboardShouldPersistTaps="handled"
80 contentContainerStyle={{
81 paddingHorizontal: 16,
82 paddingTop: android ? 16 : 0,
83 paddingBottom: 20,
84 }}
85 >
86 <View className="gap-4" pointerEvents={props.busy ? "none" : "auto"}>
87 {props.children}
88 {/* Android partial-height sheets keep submit actions within the scrollable form. */}
89 {android ? footer : null}
90 </View>
91 </ScrollView>
92 )}
93
94 {!android || props.scrollable === false ? footer : null}
95 </MaterialScreenContent>
96 </>
97 );
98 return (
99 <KeyboardAvoidingView
100 automaticOffset
101 behavior="padding"
102 collapsable={false}
103 className={props.scrollable === false ? "flex-1 bg-sheet" : "bg-sheet ios:flex-1"}
104 style={fitToContents ? { maxHeight: windowHeight * 0.92 } : undefined}
105 >
106 <NativeStackScreenOptions
107 options={{
108 gestureEnabled: !props.busy,
109 ...(android ? { sheetCornerRadius: 28 } : {}),
110 ...(fitToContents ? { sheetAllowedDetents: "fitToContents" } : {}),
111 }}
112 />
113 {content}
58114 </KeyboardAvoidingView>
59115 );
60116}
apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx+9-8
......@@ -1,6 +1,7 @@
11import { StackActions, useNavigation } from "@react-navigation/native";
22import { useState } from "react";
3import { View } from "react-native";
3import { Platform, View } from "react-native";
4import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
45import { AppText as Text } from "../../components/AppText";
56import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls";
67import { pullRequestStatus } from "./pullRequestStatus";
......@@ -12,6 +13,7 @@ export function PullRequestStackCard(props: {
1213 readonly state: PullRequestStackState;
1314}) {
1415 const navigation = useNavigation();
16 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
1517 const [expanded, setExpanded] = useState(false);
1618 const stack = props.state.stack;
1719 if (!stack) {
......@@ -62,13 +64,12 @@ export function PullRequestStackCard(props: {
6264 onPress={
6365 selected
6466 ? undefined
65 : () =>
66 navigation.dispatch(
67 StackActions.push("PullRequest", {
68 ...props.params,
69 number: layer.number,
70 }),
71 )
67 : () => {
68 const params = { ...props.params, number: layer.number };
69 if (Platform.OS === "android")
70 selectAndroidWorkspace({ name: "PullRequest", params });
71 else navigation.dispatch(StackActions.push("PullRequest", params));
72 }
7273 }
7374 last={index === stack.layers.length - 1}
7475 />
apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx+7-3
......@@ -36,6 +36,7 @@ import {
3636} from "./pullRequestList";
3737import { useDebouncedValue } from "../../state/queries";
3838import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
39import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
3940
4041function PullRequestScrollView(props: ScrollViewProps) {
4142 const insets = useSafeAreaInsets();
......@@ -77,19 +78,22 @@ export function PullRequestsScreen() {
7778 const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)
7879 ? selectedEnvironmentId
7980 : null;
81 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
8082 const selectPullRequest = useCallback(
8183 (item: EnvironmentPullRequestListEntry) => {
8284 Keyboard.dismiss();
83 navigation.navigate("PullRequest", {
85 const params = {
8486 environmentId: String(item.environmentId),
8587 projectId: String(item.projectId),
8688 repository: item.repository,
8789 number: item.number,
8890 host: item.host,
8991 initialSummary: item,
90 });
92 };
93 if (Platform.OS === "android") selectAndroidWorkspace({ name: "PullRequest", params });
94 else navigation.navigate("PullRequest", params);
9195 },
92 [navigation],
96 [navigation, selectAndroidWorkspace],
9397 );
9498 const controlsAtom = pullRequestListControlsAtom(environmentId ?? "");
9599 const controls = useAtomValue(controlsAtom);
apps/mobile/src/features/pull-requests/usePullRequestListActions.ts+10-4
......@@ -16,6 +16,7 @@ import {
1616} from "./pullRequestActions";
1717import { scopedPullRequestListKey, type EnvironmentPullRequestListEntry } from "./pullRequestList";
1818import { pullRequestTarget } from "./usePullRequestDiff";
19import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
1920
2021export function usePullRequestListActions(
2122 environmentIds: readonly EnvironmentId[],
......@@ -26,6 +27,7 @@ export function usePullRequestListActions(
2627) {
2728 const registry = use(RegistryContext);
2829 const navigation = useNavigation();
30 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
2931 const permissionKey = JSON.stringify(environmentIds);
3032 const permissionAtom = useMemo(() => {
3133 const ids: readonly EnvironmentId[] = JSON.parse(permissionKey);
......@@ -74,7 +76,9 @@ export function usePullRequestListActions(
7476 !pr.viewerPermissions.requestReviewers
7577 )
7678 throw new Error("Review requests aren't available for this pull request.");
77 navigation.navigate("PullRequestReviewers", params(entry));
79 if (Platform.OS === "android") {
80 selectAndroidWorkspace({ name: "PullRequest", params: params(entry) }, params(entry));
81 } else navigation.navigate("PullRequestReviewers", params(entry));
7882 return;
7983 }
8084 if (
......@@ -102,12 +106,14 @@ export function usePullRequestListActions(
102106 setBusyKey(null);
103107 }
104108 },
105 [registry, navigation, params, run, onChanged],
109 [registry, navigation, params, run, onChanged, selectAndroidWorkspace],
106110 );
107111 const select = useCallback(
108112 (entry: EnvironmentPullRequestListEntry, action: string) => {
109113 if (action === "open") {
110 navigation.navigate("PullRequest", params(entry));
114 if (Platform.OS === "android")
115 selectAndroidWorkspace({ name: "PullRequest", params: params(entry) });
116 else navigation.navigate("PullRequest", params(entry));
111117 return;
112118 }
113119 if (action === "request-review") {
......@@ -143,7 +149,7 @@ export function usePullRequestListActions(
143149 },
144150 ]);
145151 },
146 [navigation, params, perform],
152 [navigation, params, perform, selectAndroidWorkspace],
147153 );
148154 const actions = useCallback(
149155 (entry: EnvironmentPullRequestListEntry): ControlPillMenuProps["actions"] => [
apps/mobile/src/features/pull-requests/usePullRequestThreads.ts+8-3
......@@ -11,7 +11,8 @@ import { formatComposerContextReference } from "@t3tools/shared/composerContextR
1111import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
1212import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
1313import { useRef, useState } from "react";
14import { Alert } from "react-native";
14import { Alert, Platform } from "react-native";
15import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
1516import { pullRequestComposerContext } from "../../lib/composerContext";
1617import { scopedThreadKey } from "../../lib/scopedEntities";
1718import { uuidv4 } from "../../lib/uuid";
......@@ -70,10 +71,14 @@ export function usePullRequestThreads(
7071 const canAsk = !connected || canOperate;
7172 const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope);
7273 const navigation = useNavigation();
74 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
7375 const pending = useRef(false);
7476 const previousPrompts = useRef(new Map<string, string>());
75 const openThread = (thread: LinkedThread) =>
76 navigation.navigate("Thread", { environmentId: params.environmentId, threadId: thread.id });
77 const openThread = (thread: LinkedThread) => {
78 const destination = { environmentId: params.environmentId, threadId: thread.id };
79 if (Platform.OS === "android") selectAndroidWorkspace({ name: "Thread", params: destination });
80 else navigation.navigate("Thread", destination);
81 };
7782 const [busy, setBusy] = useState(false);
7883 const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => {
7984 if (!pr || !canAsk || pending.current) return;
apps/mobile/src/features/threads/ThreadHeader.tsx+3-12
......@@ -1,4 +1,3 @@
1import { StackActions, useNavigation } from "@react-navigation/native";
21import { useMemo } from "react";
32import { ScreenHeader } from "../../components/ScreenHeader";
43import { ScreenHeaderButton } from "../../components/ScreenHeaderButton";
......@@ -6,6 +5,7 @@ import type { ScreenHeaderAction } from "../../components/ScreenHeader.types";
65import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
76import type { ThreadInspectorMode } from "./thread-inspector-content-stack";
87import { useThreadHeaderOptions } from "./useThreadHeaderOptions";
8import { useWorkspaceListNavigation } from "../workspace/useAndroidWorkspaceNavigation";
99
1010export function ThreadHeader(
1111 props: Parameters<typeof useThreadHeaderOptions>[0] & {
......@@ -18,7 +18,7 @@ export function ThreadHeader(
1818 readonly onOpenFilesInspector: () => void;
1919 },
2020) {
21 const navigation = useNavigation();
21 const returnToList = useWorkspaceListNavigation("threads");
2222 const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
2323 const { onOpenTerminal, onMergeBack } = props.gitControls;
2424 const native = useThreadHeaderOptions(props);
......@@ -96,16 +96,7 @@ export function ThreadHeader(
9696 />
9797 ) : null
9898 }
99 onBack={
100 layout.usesSplitView
101 ? undefined
102 : () => {
103 // A deep link or cold start has no previous route; Home is the way out.
104 // Read the history at press time: it changes without re-rendering this screen.
105 if (navigation.canGoBack()) navigation.goBack();
106 else navigation.dispatch(StackActions.replace("Home"));
107 }
108 }
99 onBack={layout.usesSplitView ? undefined : returnToList}
109100 actions={androidHeaderActions}
110101 hideBottomBorder
111102 />
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+7-9
......@@ -87,6 +87,10 @@ import {
8787} from "./thread-inspector-content-stack";
8888import { threadRouteIsHydrating } from "./thread-route-hydration";
8989import { ThreadHeader } from "./ThreadHeader";
90import {
91 useAndroidWorkspaceBack,
92 useWorkspaceListNavigation,
93} from "../workspace/useAndroidWorkspaceNavigation";
9094
9195interface ThreadInspectorSelection {
9296 readonly routeThreadIdentity: string | null;
......@@ -114,7 +118,7 @@ function ThreadPane(props: { readonly children: ReactNode }) {
114118}
115119
116120function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) {
117 const navigation = useNavigation();
121 const returnToList = useWorkspaceListNavigation("threads");
118122 const { layout } = useAdaptiveWorkspaceLayout();
119123 const { themeVariables } = useAppearancePreferences();
120124 if (Platform.OS !== "android") return props.children;
......@@ -124,14 +128,7 @@ function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) {
124128 <ScreenHeader
125129 title="Thread"
126130 options={{ contentStyle: { backgroundColor: themeVariables["--color-header"] } }}
127 onBack={
128 layout.usesSplitView
129 ? undefined
130 : () => {
131 if (navigation.canGoBack()) navigation.goBack();
132 else navigation.dispatch(StackActions.replace("Home"));
133 }
134 }
131 onBack={layout.usesSplitView ? undefined : returnToList}
135132 hideBottomBorder
136133 />
137134 <ThreadPane>{props.children}</ThreadPane>
......@@ -190,6 +187,7 @@ function ThreadUnavailableScreen(props: {
190187}
191188
192189export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
190 useAndroidWorkspaceBack("threads");
193191 const connectionsReady = useConnectionsReady();
194192 const { connectionState } = useRemoteConnectionStatus();
195193 const { selectedThread } = useThreadSelection();
apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx created+98
......@@ -0,0 +1,98 @@
1import { requireNativeView } from "expo";
2import { CommonActions, useNavigation } from "@react-navigation/native";
3import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation";
4import {
5 Keyboard,
6 processColor,
7 View,
8 type NativeSyntheticEvent,
9 type ViewProps,
10} from "react-native";
11import { useSafeAreaInsets } from "react-native-safe-area-context";
12import { useKeyboardState } from "react-native-keyboard-controller";
13
14import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
15import { flattenThemeColor, themeColorWithAlpha } from "../../lib/mobileTheme";
16import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
17import { useWorkspaceTabs } from "./WorkspaceTabsContext";
18import type { WorkspaceTab } from "./workspace-tabs-state";
19
20export const ANDROID_WORKSPACE_RAIL_WIDTH = 80;
21
22interface NativeNavigationProps extends ViewProps {
23 readonly rail: boolean;
24 readonly selectedTab: WorkspaceTab;
25 readonly dark: boolean;
26 readonly textScale: number;
27 readonly containerColor: number;
28 readonly selectedColor: number;
29 readonly indicatorColor: number;
30 readonly unselectedColor: number;
31 readonly onTabSelected: (event: NativeSyntheticEvent<{ readonly tab: WorkspaceTab }>) => void;
32}
33
34const NativeNavigation = requireNativeView<NativeNavigationProps>("T3WorkspaceNavigation");
35
36function nativeColor(value: string) {
37 const color = processColor(value);
38 return typeof color === "number" ? color : 0;
39}
40
41export function AndroidWorkspaceNavigation(props: {
42 readonly rail?: boolean;
43 readonly onSelect?: () => void;
44}) {
45 const workspace = useWorkspaceTabs()!;
46 const navigation = useNavigation();
47 const insets = useSafeAreaInsets();
48 const keyboardVisible = useKeyboardState((state) => state.isVisible);
49 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
50 const { scale } = useAndroidControlSizing();
51 const rail = Boolean(props.rail);
52 const containerColor = rail
53 ? colors["--color-header"]
54 : flattenThemeColor(
55 themeColorWithAlpha(colors["--color-header-foreground"], 0.05),
56 flattenThemeColor(colors["--color-header"], colors["--color-screen"]),
57 );
58 if (!rail && keyboardVisible) return null;
59
60 return (
61 <View
62 style={{
63 backgroundColor: containerColor,
64 ...(rail
65 ? {
66 flex: 1,
67 width: ANDROID_WORKSPACE_RAIL_WIDTH * Math.max(1, scale),
68 paddingTop: insets.top,
69 paddingBottom: insets.bottom,
70 }
71 : { paddingBottom: insets.bottom }),
72 }}
73 >
74 <NativeNavigation
75 rail={rail}
76 selectedTab={workspace.state.selectedTab}
77 dark={themeAppearance === "dark"}
78 textScale={scale}
79 containerColor={nativeColor(containerColor)}
80 selectedColor={nativeColor(colors["--color-secondary-foreground"])}
81 indicatorColor={nativeColor(colors["--color-secondary"])}
82 unselectedColor={nativeColor(colors["--color-header-foreground"])}
83 onTabSelected={(event) => {
84 Keyboard.dismiss();
85 const tab = event.nativeEvent.tab;
86 if (tab !== workspace.state.selectedTab) {
87 workspace.dispatch({ type: "select", tab });
88 navigation.dispatch((state) =>
89 CommonActions.reset(androidWorkspaceNavigationState(state)),
90 );
91 }
92 props.onSelect?.();
93 }}
94 style={rail ? { flex: 1 } : { height: 80 * Math.max(1, scale) }}
95 />
96 </View>
97 );
98}
apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx created+9
......@@ -0,0 +1,9 @@
1/** Other clients own their workspace navigation chrome. */
2export function AndroidWorkspaceNavigation(_props: {
3 readonly rail?: boolean;
4 readonly onSelect?: () => void;
5}) {
6 return null;
7}
8
9export const ANDROID_WORKSPACE_RAIL_WIDTH = 80;
apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx created+26
......@@ -0,0 +1,26 @@
1import { useMemo, type ReactNode } from "react";
2import { View } from "react-native";
3import { MaterialScreenContent } from "../../components/MaterialScreenContent";
4import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen";
5import { MaterialPullRequestToolbar } from "../pull-requests/MaterialPullRequestToolbar.android";
6import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";
7
8export function AndroidWorkspaceSidebar(props: {
9 readonly children: ReactNode;
10 readonly onRequestVisibility: () => void;
11}) {
12 const workspace = useWorkspaceTabs()!;
13 const context = useMemo(() => ({ ...workspace, bottomClearance: 16 }), [workspace]);
14 return workspace.state.selectedTab === "threads" ? (
15 props.children
16 ) : (
17 <WorkspaceTabsContext value={context}>
18 <View className="flex-1 bg-header">
19 <MaterialPullRequestToolbar onRequestVisibility={props.onRequestVisibility} />
20 <MaterialScreenContent insetHorizontal>
21 <PullRequestsScreen />
22 </MaterialScreenContent>
23 </View>
24 </WorkspaceTabsContext>
25 );
26}
apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.tsx created+8
......@@ -0,0 +1,8 @@
1import type { ReactNode } from "react";
2
3export function AndroidWorkspaceSidebar(props: {
4 readonly children: ReactNode;
5 readonly onRequestVisibility: () => void;
6}) {
7 return props.children;
8}
apps/mobile/src/features/workspace/AndroidWorkspaceTabsProvider.tsx created+12
......@@ -0,0 +1,12 @@
1import type { ReactNode } from "react";
2import { Platform } from "react-native";
3import { WorkspaceTabsProvider } from "./WorkspaceTabsContext";
4
5/** Android's persistent sidebar and compact root share one tab controller. */
6export function AndroidWorkspaceTabsProvider(props: { readonly children: ReactNode }) {
7 return Platform.OS === "android" ? (
8 <WorkspaceTabsProvider>{props.children}</WorkspaceTabsProvider>
9 ) : (
10 props.children
11 );
12}
apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx created+70
......@@ -0,0 +1,70 @@
1import { useMemo } from "react";
2import { View } from "react-native";
3import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
4import { NativeStackScreenOptions } from "../../native/StackHeader";
5import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
6import { EmptyState } from "../../components/EmptyState";
7import { MaterialScreenContent } from "../../components/MaterialScreenContent";
8import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
9import { HomeRouteScreen } from "../home/HomeRouteScreen";
10import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen";
11import { MaterialPullRequestToolbar } from "../pull-requests/MaterialPullRequestToolbar.android";
12import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
13import { AndroidWorkspaceNavigation } from "./AndroidWorkspaceNavigation";
14import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";
15
16export function WorkspaceRootScreen() {
17 const { layout } = useAdaptiveWorkspaceLayout();
18 const workspace = useWorkspaceTabs()!;
19 const insets = useSafeAreaInsets();
20 const { fabClearance } = useAndroidControlSizing();
21 const context = useMemo(
22 () => ({
23 ...workspace,
24 bottomClearance: workspace.state.selectedTab === "threads" ? fabClearance + 16 : 16,
25 }),
26 [workspace, fabClearance],
27 );
28
29 // Split lists live beside the navigator; Home remains an empty detail pane.
30 if (layout.usesSplitView) {
31 return workspace.state.selectedTab === "threads" ? (
32 <HomeRouteScreen />
33 ) : (
34 <>
35 <NativeStackScreenOptions options={{ headerShown: false }} />
36 <AndroidScreenHeader title="Pull requests" />
37 <View className="flex-1 items-center justify-center px-10">
38 <EmptyState
39 title="Select a pull request"
40 detail="Choose a pull request from the sidebar to review it."
41 variant="plain"
42 />
43 </View>
44 </>
45 );
46 }
47
48 return (
49 <WorkspaceTabsContext value={context}>
50 <View className="flex-1 bg-header">
51 <NativeStackScreenOptions options={{ headerShown: false }} />
52 <SafeAreaInsetsContext value={{ ...insets, bottom: 0 }}>
53 <View className="flex-1">
54 {workspace.state.selectedTab === "threads" ? (
55 <HomeRouteScreen />
56 ) : (
57 <>
58 <MaterialPullRequestToolbar />
59 <MaterialScreenContent>
60 <PullRequestsScreen />
61 </MaterialScreenContent>
62 </>
63 )}
64 </View>
65 </SafeAreaInsetsContext>
66 <AndroidWorkspaceNavigation />
67 </View>
68 </WorkspaceTabsContext>
69 );
70}
apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts created+42
......@@ -0,0 +1,42 @@
1import {
2 CommonActions,
3 StackActions,
4 useFocusEffect,
5 useNavigation,
6} from "@react-navigation/native";
7import { useCallback } from "react";
8import { BackHandler, Keyboard, Platform } from "react-native";
9import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation";
10import { useWorkspaceTabs } from "./WorkspaceTabsContext";
11import type { WorkspaceTab } from "./workspace-tabs-state";
12
13export function useWorkspaceListNavigation(tab: WorkspaceTab) {
14 const navigation = useNavigation();
15 const dispatch = useWorkspaceTabs()?.dispatch;
16 return useCallback(() => {
17 if (Platform.OS === "android") {
18 dispatch?.({ type: "select", tab });
19 navigation.dispatch((state) => CommonActions.reset(androidWorkspaceNavigationState(state)));
20 } else if (navigation.canGoBack()) navigation.goBack();
21 else navigation.dispatch(StackActions.replace("Home"));
22 }, [navigation, dispatch, tab]);
23}
24
25/** Only base details own this Back handler; focused sheets and nested screens take priority. */
26export function useAndroidWorkspaceBack(tab: WorkspaceTab) {
27 const dispatch = useWorkspaceTabs()?.dispatch;
28 const returnToList = useWorkspaceListNavigation(tab);
29 useFocusEffect(
30 useCallback(() => {
31 if (Platform.OS !== "android") return;
32 dispatch?.({ type: "select", tab });
33 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
34 if (Keyboard.isVisible()) Keyboard.dismiss();
35 else returnToList();
36 return true;
37 });
38 return () => subscription.remove();
39 }, [returnToList, dispatch, tab]),
40 );
41 return returnToList;
42}
apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts created+29
......@@ -0,0 +1,29 @@
1import { CommonActions, useNavigation } from "@react-navigation/native";
2import { useCallback } from "react";
3import { Keyboard } from "react-native";
4import {
5 androidWorkspaceNavigationState,
6 type AndroidWorkspaceDestination,
7} from "../../lib/android-workspace-navigation";
8import { useWorkspaceTabs } from "./WorkspaceTabsContext";
9
10export function useAndroidWorkspaceSelection() {
11 const navigation = useNavigation();
12 const dispatch = useWorkspaceTabs()?.dispatch;
13 return useCallback(
14 (
15 destination: AndroidWorkspaceDestination,
16 reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"],
17 ) => {
18 Keyboard.dismiss();
19 dispatch?.({
20 type: "select",
21 tab: destination.name === "Thread" ? "threads" : "pullRequests",
22 });
23 navigation.dispatch((state) =>
24 CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)),
25 );
26 },
27 [navigation, dispatch],
28 );
29}
apps/mobile/src/lib/android-workspace-navigation.test.ts created+186
......@@ -0,0 +1,186 @@
1import * as NodeModule from "node:module";
2import { describe, expect, it } from "vite-plus/test";
3import {
4 androidWorkspaceNavigationState,
5 type AndroidWorkspaceDestination,
6} from "./android-workspace-navigation";
7
8const require = NodeModule.createRequire(import.meta.url);
9const nativePackage = require.resolve("@react-navigation/native/package.json");
10const requireFromNative = NodeModule.createRequire(nativePackage);
11const corePackage = requireFromNative.resolve("@react-navigation/core/package.json");
12const requireFromCore = NodeModule.createRequire(corePackage);
13const { CommonActions, StackActions, StackRouter } = requireFromCore(
14 "@react-navigation/routers",
15) as {
16 readonly CommonActions: typeof import("@react-navigation/native").CommonActions;
17 readonly StackActions: typeof import("@react-navigation/native").StackActions;
18 readonly StackRouter: typeof import("@react-navigation/native").StackRouter;
19};
20const router = StackRouter({});
21const options = {
22 routeNames: [
23 "Home",
24 "Thread",
25 "ThreadFiles",
26 "ThreadTerminal",
27 "GitOverview",
28 "PullRequest",
29 "PullRequestFile",
30 "PullRequestReviewers",
31 "SettingsSheet",
32 ],
33 routeParamList: {},
34 routeGetIdList: {},
35};
36const thread = {
37 name: "Thread",
38 params: { environmentId: "environment-a", threadId: "thread-a" },
39} as const;
40const pullRequest = {
41 name: "PullRequest",
42 params: {
43 environmentId: "environment-a",
44 projectId: "project-a",
45 repository: "owner/repository",
46 number: 42,
47 host: "github.com",
48 },
49} as const;
50
51function apply(
52 state: ReturnType<typeof router.getInitialState>,
53 action: Parameters<typeof router.getStateForAction>[1],
54) {
55 const result = router.getStateForAction(state, action, options);
56 if (!result) throw new Error(`Router rejected ${action.type}`);
57 return router.getRehydratedState(result, options);
58}
59
60function select(
61 state: ReturnType<typeof router.getInitialState>,
62 destination?: AndroidWorkspaceDestination,
63 reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"],
64) {
65 return apply(
66 state,
67 CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)),
68 );
69}
70
71function legacyHistory() {
72 let state = router.getInitialState(options);
73 state = apply(state, StackActions.push(thread.name, thread.params));
74 state = apply(state, StackActions.push(pullRequest.name, pullRequest.params));
75 return state;
76}
77
78function names(state: ReturnType<typeof router.getInitialState>) {
79 return state.routes.map((route) => route.name);
80}
81
82describe("Android workspace navigation", () => {
83 it("replaces cross-tab history with one detail and returns directly to its list", () => {
84 const previous = legacyHistory();
85 const state = select(previous, pullRequest);
86 expect(names(state)).toEqual(["Home", "PullRequest"]);
87 expect(state.routes[0]?.key).toBe(previous.routes[0]?.key);
88 expect(state.routes[1]?.key).toBe(previous.routes[2]?.key);
89 expect(names(apply(state, StackActions.pop()))).toEqual(["Home"]);
90 });
91
92 it("replaces peer thread selections without leaving the previous thread underneath", () => {
93 const first = select(router.getInitialState(options), thread);
94 const second = select(first, {
95 name: "Thread",
96 params: { ...thread.params, threadId: "thread-b" },
97 });
98 expect(names(second)).toEqual(["Home", "Thread"]);
99 expect(second.routes[1]?.params).toEqual({ ...thread.params, threadId: "thread-b" });
100 expect(second.routes[1]?.key).not.toBe(first.routes[1]?.key);
101 expect(names(apply(second, StackActions.pop()))).toEqual(["Home"]);
102 });
103
104 it("dismisses nested content and overlays when the sidebar selects a peer", () => {
105 let state = legacyHistory();
106 state = apply(
107 state,
108 StackActions.push("PullRequestFile", { ...pullRequest.params, path: "src/main.ts" }),
109 );
110 state = apply(state, StackActions.push("SettingsSheet"));
111 const selected = select(state, thread);
112 expect(names(selected)).toEqual(["Home", "Thread"]);
113 expect(selected.routes[1]?.params).toEqual(thread.params);
114 });
115
116 it("creates Home beneath cold-linked details and gives direct list escape", () => {
117 const initial = router.getInitialState(options);
118 const cold = apply(initial, CommonActions.reset({ index: 0, routes: [pullRequest] }));
119 expect(names(select(cold, pullRequest))).toEqual(["Home", "PullRequest"]);
120 expect(names(select(cold))).toEqual(["Home"]);
121 });
122
123 it("keeps a request-review flow above the chosen PR instead of an old chat", () => {
124 const state = select(legacyHistory(), pullRequest, pullRequest.params);
125 expect(names(state)).toEqual(["Home", "PullRequest", "PullRequestReviewers"]);
126 const back = apply(state, StackActions.pop());
127 expect(names(back)).toEqual(["Home", "PullRequest"]);
128 expect(back.routes[back.index]?.params).toEqual(pullRequest.params);
129 });
130
131 it("preserves normal nested PR Back behavior", () => {
132 const detail = select(legacyHistory(), pullRequest);
133 const nested = apply(
134 detail,
135 StackActions.push("PullRequestFile", { ...pullRequest.params, path: "file.ts" }),
136 );
137 const back = apply(nested, StackActions.pop());
138 expect(back.routes[back.index]?.key).toBe(detail.routes[detail.index]?.key);
139 expect(names(back)).toEqual(["Home", "PullRequest"]);
140 });
141
142 it.each(["ThreadFiles", "ThreadTerminal", "GitOverview"])(
143 "returns to chat when its sidebar row is selected from %s",
144 (route) => {
145 const detail = select(router.getInitialState(options), thread);
146 const nested = apply(detail, StackActions.push(route, thread.params));
147 const selected = select(nested, thread);
148 expect(names(selected)).toEqual(["Home", "Thread"]);
149 expect(selected.routes[1]?.key).toBe(detail.routes[1]?.key);
150 expect(names(apply(selected, StackActions.pop()))).toEqual(["Home"]);
151 },
152 );
153
154 it.each([
155 { environmentId: "environment-b" },
156 { projectId: "project-b" },
157 { repository: "owner/other" },
158 { host: "git.example.test" },
159 { number: 43 },
160 ])("does not reuse another PR's key when its scope differs: %o", (change) => {
161 const previous = select(router.getInitialState(options), pullRequest);
162 const state = select(previous, {
163 name: "PullRequest",
164 params: { ...pullRequest.params, ...change },
165 });
166 expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key);
167 expect(state.routes[1]?.params).toEqual({ ...pullRequest.params, ...change });
168 });
169
170 it("does not reuse a thread key from another environment", () => {
171 const previous = select(router.getInitialState(options), thread);
172 const state = select(previous, {
173 name: "Thread",
174 params: { ...thread.params, environmentId: "environment-b" },
175 });
176 expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key);
177 });
178
179 it("drops stale detail history on a tab switch while retaining Home", () => {
180 const previous = legacyHistory();
181 const state = select(previous);
182 expect(names(state)).toEqual(["Home"]);
183 expect(state.routes[0]?.key).toBe(previous.routes[0]?.key);
184 expect(state.index).toBe(0);
185 });
186});
apps/mobile/src/lib/android-workspace-navigation.ts created+34
......@@ -0,0 +1,34 @@
1import type { NavigationState } from "@react-navigation/native";
2
3export type AndroidWorkspaceDestination =
4 | { readonly name: "Thread"; readonly params: ReactNavigation.RootParamList["Thread"] }
5 | { readonly name: "PullRequest"; readonly params: ReactNavigation.RootParamList["PullRequest"] };
6
7/** Peer details share one slot above Home; nested routes retain ordinary back navigation. */
8export function androidWorkspaceNavigationState(
9 state: NavigationState,
10 destination?: AndroidWorkspaceDestination,
11 reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"],
12) {
13 const home = state.routes.find((route) => route.name === "Home") ?? { name: "Home" };
14 if (!destination) return { ...state, index: 0, routes: [home] };
15 const identity =
16 destination.name === "Thread"
17 ? ["environmentId", "threadId"]
18 : ["environmentId", "projectId", "repository", "number", "host"];
19 const params = destination.params as Readonly<Record<string, unknown>>;
20 const existing = state.routes.findLast((route) => {
21 if (route.name !== destination.name) return false;
22 const current = route.params as Readonly<Record<string, unknown>> | undefined;
23 return identity.every((key) => current?.[key] === params[key]);
24 });
25 return {
26 ...state,
27 index: reviewers ? 2 : 1,
28 routes: [
29 home,
30 { ...existing, ...destination },
31 ...(reviewers ? [{ name: "PullRequestReviewers", params: reviewers }] : []),
32 ],
33 };
34}
apps/mobile/src/native/NativeHeaderToolbar.tsx+11-4
......@@ -49,10 +49,17 @@ type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {
4949};
5050
5151function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {
52 if (typeof icon !== "string") {
53 return undefined;
52 if (typeof icon === "string") return { type: "sfSymbol", name: icon as never };
53 if (
54 typeof icon === "object" &&
55 icon !== null &&
56 "type" in icon &&
57 icon.type === "image" &&
58 "source" in icon
59 ) {
60 return icon as NativeStackHeaderIcon;
5461 }
55 return { type: "sfSymbol", name: icon as never };
62 return undefined;
5663}
5764
5865type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode };
......@@ -275,7 +282,7 @@ function NativeHeaderToolbarButton(_props: {
275282 readonly accessibilityLabel?: string;
276283 readonly axisBehavior?: NativeStackHeaderAxisBehavior;
277284 readonly disabled?: boolean;
278 readonly icon?: string;
285 readonly icon?: string | Extract<NativeStackHeaderIcon, { type: "image" }>;
279286 readonly label?: string;
280287 readonly onPress?: () => void;
281288 readonly separateBackground?: boolean;
apps/mobile/src/native/useOpenPullRequest.ts+10-1
......@@ -1,6 +1,9 @@
11import { useNavigation, useRoute, StackActions } from "@react-navigation/native";
22import { nativePullRequestDestination } from "../lib/pullRequestNavigation";
33import { useCallback } from "react";
4import { Platform } from "react-native";
5import { CommonActions } from "@react-navigation/native";
6import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation";
47import { EnvironmentId, ThreadId } from "@t3tools/contracts";
58import { useThreadShell } from "../state/entities";
69import { tryOpenExternalUrl } from "../lib/openExternalUrl";
......@@ -42,7 +45,13 @@ export function useOpenPullRequest(options?: {
4245 : null,
4346 );
4447 if (!params) return tryOpenExternalUrl(url, "pull-request");
45 if (replace) navigation.dispatch(StackActions.replace("PullRequest", params));
48 if (Platform.OS === "android")
49 navigation.dispatch((state) =>
50 CommonActions.reset(
51 androidWorkspaceNavigationState(state, { name: "PullRequest", params }),
52 ),
53 );
54 else if (replace) navigation.dispatch(StackActions.replace("PullRequest", params));
4655 else navigation.navigate("PullRequest", params);
4756 return true;
4857 },