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 @@...@@ -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
1apply plugin: 'com.android.library'8apply plugin: 'com.android.library'
2apply plugin: 'org.jetbrains.kotlin.android'9apply plugin: 'org.jetbrains.kotlin.android'
10apply plugin: 'org.jetbrains.kotlin.plugin.compose'
311
4group = 'com.t3tools.nativecontrols'12group = 'com.t3tools.nativecontrols'
5version = '0.0.0'13version = '0.0.0'
...@@ -12,8 +20,11 @@ android {...@@ -12,8 +20,11 @@ android {
12 minSdkVersion rootProject.ext.minSdkVersion20 minSdkVersion rootProject.ext.minSdkVersion
13 targetSdkVersion rootProject.ext.targetSdkVersion21 targetSdkVersion rootProject.ext.targetSdkVersion
14 }22 }
23 buildFeatures { compose true }
15}24}
1625
17dependencies {26dependencies {
18 implementation project(':expo-modules-core')27 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'
19}30}
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 @@...@@ -7,6 +7,7 @@
7 "android": {7 "android": {
8 "modules": [8 "modules": [
9 "expo.modules.t3nativecontrols.T3NativeControlsModule",9 "expo.modules.t3nativecontrols.T3NativeControlsModule",
10 "expo.modules.t3nativecontrols.T3WorkspaceNavigationModule",
10 "expo.modules.t3nativecontrols.T3KeyboardCommandsModule"11 "expo.modules.t3nativecontrols.T3KeyboardCommandsModule"
11 ]12 ]
12 }13 }
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt+3
...@@ -41,6 +41,9 @@ class T3ReviewDiffModule : Module() {...@@ -41,6 +41,9 @@ class T3ReviewDiffModule : Module() {
41 Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double ->41 Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double ->
42 view.setContentWidth(contentWidth.toFloat())42 view.setContentWidth(contentWidth.toFloat())
43 }43 }
44 Prop("contentInsetBottom") { view: T3ReviewDiffView, value: Double ->
45 view.setContentInsetBottom(value.toFloat())
46 }
44 Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean ->47 Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean ->
45 view.setScrollEnabled(enabled)48 view.setScrollEnabled(enabled)
46 }49 }
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...@@ -143,6 +143,12 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
143 canvasView.contentWidthPx = max(width, dp(value).toInt())143 canvasView.contentWidthPx = max(width, dp(value).toInt())
144 }144 }
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
146 fun setInitialRowIndex(value: Double) {152 fun setInitialRowIndex(value: Double) {
147 initialRowIndex = value.toInt().coerceAtLeast(0)153 initialRowIndex = value.toInt().coerceAtLeast(0)
148 pendingInitialScroll = true154 pendingInitialScroll = true
...@@ -818,7 +824,7 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -818,7 +824,7 @@ private class DiffCanvasView(context: Context) : View(context) {
818 fun rowTop(index: Int): Int = rowOffsets[index.coerceIn(0, max(0, rowOffsets.size - 2))]824 fun rowTop(index: Int): Int = rowOffsets[index.coerceIn(0, max(0, rowOffsets.size - 2))]
819825
820 fun setVerticalOffset(value: Int) {826 fun setVerticalOffset(value: Int) {
821 val maxOffset = max(0, (rowOffsets.lastOrNull() ?: 0) - height)827 val maxOffset = maxVerticalOffset()
822 val nextOffset = value.coerceIn(0, maxOffset)828 val nextOffset = value.coerceIn(0, maxOffset)
823 if (verticalOffset == nextOffset) return829 if (verticalOffset == nextOffset) return
824 verticalOffset = nextOffset830 verticalOffset = nextOffset
...@@ -832,7 +838,8 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -832,7 +838,8 @@ private class DiffCanvasView(context: Context) : View(context) {
832838
833 fun verticalOffset(): Int = verticalOffset839 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
837 fun setHorizontalOffset(value: Int) {844 fun setHorizontalOffset(value: Int) {
838 val nextOffset = value.coerceIn(0, maxHorizontalOffset())845 val nextOffset = value.coerceIn(0, maxHorizontalOffset())
...@@ -932,8 +939,9 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -932,8 +939,9 @@ private class DiffCanvasView(context: Context) : View(context) {
932 return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) }939 return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) }
933 }940 }
934 }941 }
935 val index = rowIndexAt(verticalOffset + y.toInt())942 val contentY = verticalOffset + y.toInt()
936 return rows.getOrNull(index)?.let {943 val index = rowIndexAt(contentY)
944 return rows.getOrNull(index)?.takeIf { contentY in 0 until rowOffsets.last() }?.let {
937 RowHit(945 RowHit(
938 row = it,946 row = it,
939 top = rowOffsets[index] - verticalOffset,947 top = rowOffsets[index] - verticalOffset,
apps/mobile/src/Stack.tsx+21-12
...@@ -31,6 +31,7 @@ import { useResolveClassNames } from "uniwind";...@@ -31,6 +31,7 @@ import { useResolveClassNames } from "uniwind";
31import { AppText as Text } from "./components/AppText";31import { AppText as Text } from "./components/AppText";
32import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";32import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
33import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen";33import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen";
34import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider";
34import {35import {
35 PullRequestRouteScreen,36 PullRequestRouteScreen,
36 PullRequestFileRouteScreen,37 PullRequestFileRouteScreen,
...@@ -170,6 +171,12 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {...@@ -170,6 +171,12 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
170 unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined,171 unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined,
171};172};
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
173// SOLID: opaque sheet-colored header for surfaces that manage their own viewport.180// SOLID: opaque sheet-colored header for surfaces that manage their own viewport.
174const SOLID_HEADER_OPTIONS: AppScreenOptions = {181const SOLID_HEADER_OPTIONS: AppScreenOptions = {
175 headerBackButtonDisplayMode: "minimal",182 headerBackButtonDisplayMode: "minimal",
...@@ -647,13 +654,15 @@ function RootStackLayout(props: {...@@ -647,13 +654,15 @@ function RootStackLayout(props: {
647 <ThreadOutboxDrainWorker />654 <ThreadOutboxDrainWorker />
648 <ShowcaseCaptureCoordinator pathname={pathname} />655 <ShowcaseCaptureCoordinator pathname={pathname} />
649 <ExistingThreadSettingsRouteProvider>656 <ExistingThreadSettingsRouteProvider>
650 <AdaptiveWorkspaceLayout657 <AndroidWorkspaceTabsProvider>
651 pathname={workspaceLocation.pathname}658 <AdaptiveWorkspaceLayout
652 workspaceRouteKey={workspaceLocation.routeKey}659 pathname={workspaceLocation.pathname}
653 >660 workspaceRouteKey={workspaceLocation.routeKey}
654 {props.children}661 >
655 <HardwareKeyboardCommandOverlay />662 {props.children}
656 </AdaptiveWorkspaceLayout>663 <HardwareKeyboardCommandOverlay />
664 </AdaptiveWorkspaceLayout>
665 </AndroidWorkspaceTabsProvider>
657 </ExistingThreadSettingsRouteProvider>666 </ExistingThreadSettingsRouteProvider>
658 </HardwareKeyboardCommandProvider>667 </HardwareKeyboardCommandProvider>
659 );668 );
...@@ -721,15 +730,15 @@ const RootStackConfig = createWorkspaceStackNavigator({...@@ -721,15 +730,15 @@ const RootStackConfig = createWorkspaceStackNavigator({
721 path: "pull-requests/:environmentId/:projectId/:repository/:number",730 path: "pull-requests/:environmentId/:projectId/:repository/:number",
722 parse: { number: Number },731 parse: { number: Number },
723 },732 },
724 options: GLASS_HEADER_OPTIONS,733 options: PULL_REQUEST_HEADER_OPTIONS,
725 }),734 }),
726 PullRequestGuideStop: createNativeStackScreen({735 PullRequestGuideStop: createNativeStackScreen({
727 screen: PullRequestGuideStopScreen,736 screen: PullRequestGuideStopScreen,
728 options: GLASS_HEADER_OPTIONS,737 options: PULL_REQUEST_HEADER_OPTIONS,
729 }),738 }),
730 PullRequestFile: createNativeStackScreen({739 PullRequestFile: createNativeStackScreen({
731 screen: PullRequestFileRouteScreen,740 screen: PullRequestFileRouteScreen,
732 options: GLASS_HEADER_OPTIONS,741 options: PULL_REQUEST_HEADER_OPTIONS,
733 }),742 }),
734 PullRequestFiles: createNativeStackScreen({743 PullRequestFiles: createNativeStackScreen({
735 screen: PullRequestFilesRouteScreen,744 screen: PullRequestFilesRouteScreen,
...@@ -743,7 +752,7 @@ const RootStackConfig = createWorkspaceStackNavigator({...@@ -743,7 +752,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
743 PullRequestReviewers: createNativeStackScreen({752 PullRequestReviewers: createNativeStackScreen({
744 screen: PullRequestReviewersScreen,753 screen: PullRequestReviewersScreen,
745 options: {754 options: {
746 ...GLASS_HEADER_OPTIONS,755 ...PULL_REQUEST_HEADER_OPTIONS,
747 ...FORM_SHEET_PRESENTATION_OPTIONS,756 ...FORM_SHEET_PRESENTATION_OPTIONS,
748 sheetAllowedDetents: [0.75, 0.92],757 sheetAllowedDetents: [0.75, 0.92],
749 sheetGrabberVisible: true,758 sheetGrabberVisible: true,
...@@ -752,7 +761,7 @@ const RootStackConfig = createWorkspaceStackNavigator({...@@ -752,7 +761,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
752 }),761 }),
753 PullRequestDiscussion: createNativeStackScreen({762 PullRequestDiscussion: createNativeStackScreen({
754 screen: PullRequestDiscussionScreen,763 screen: PullRequestDiscussionScreen,
755 options: GLASS_HEADER_OPTIONS,764 options: PULL_REQUEST_HEADER_OPTIONS,
756 }),765 }),
757 PullRequestInlineComment: createNativeStackScreen({766 PullRequestInlineComment: createNativeStackScreen({
758 screen: PullRequestInlineCommentScreen,767 screen: PullRequestInlineCommentScreen,
apps/mobile/src/components/AppSymbol.ios.tsx+6-4
...@@ -5,6 +5,7 @@ import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequ...@@ -5,6 +5,7 @@ import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequ
5import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft";5import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft";
6import type { SFSymbol } from "expo-symbols";6import type { SFSymbol } from "expo-symbols";
7import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest";7import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest";
8import IconFileDiff from "@tabler/icons-react-native/IconFileDiff";
8import { SymbolView as ExpoSymbolView } from "expo-symbols";9import { SymbolView as ExpoSymbolView } from "expo-symbols";
9import { withUniwind } from "uniwind";10import { withUniwind } from "uniwind";
10import type { AppSymbolViewProps } from "./AppSymbol";11import type { AppSymbolViewProps } from "./AppSymbol";
...@@ -12,7 +13,8 @@ import type { AppSymbolViewProps } from "./AppSymbol";...@@ -12,7 +13,8 @@ import type { AppSymbolViewProps } from "./AppSymbol";
12export type { SFSymbol } from "expo-symbols";13export type { SFSymbol } from "expo-symbols";
13export type { AppSymbolName } from "./AppSymbol";14export type { AppSymbolName } from "./AppSymbol";
1415
15const GIT_ICONS = {16const CUSTOM_ICONS = {
17 "file.diff": IconFileDiff,
16 "arrow.triangle.pull": IconGitPullRequest,18 "arrow.triangle.pull": IconGitPullRequest,
17 "arrow.triangle.merge": IconGitMerge,19 "arrow.triangle.merge": IconGitMerge,
18 "arrow.triangle.branch": IconGitBranch,20 "arrow.triangle.branch": IconGitBranch,
...@@ -24,10 +26,10 @@ const GIT_ICONS = {...@@ -24,10 +26,10 @@ const GIT_ICONS = {
2426
25function AppSymbolView(props: AppSymbolViewProps) {27function AppSymbolView(props: AppSymbolViewProps) {
26 const name = typeof props.name === "string" ? props.name : props.name.ios;28 const name = typeof props.name === "string" ? props.name : props.name.ios;
27 const GitIcon = GIT_ICONS[name as keyof typeof GIT_ICONS];29 const CustomIcon = CUSTOM_ICONS[name as keyof typeof CUSTOM_ICONS];
28 if (GitIcon) {30 if (CustomIcon) {
29 return (31 return (
30 <GitIcon32 <CustomIcon
31 accessibilityLabel={props.accessibilityLabel}33 accessibilityLabel={props.accessibilityLabel}
32 color={props.tintColor}34 color={props.tintColor}
33 size={props.size}35 size={props.size}
apps/mobile/src/components/AppSymbol.tsx+10-2
...@@ -58,6 +58,7 @@ import IconExternalLink from "@tabler/icons-react-native/IconExternalLink";...@@ -58,6 +58,7 @@ import IconExternalLink from "@tabler/icons-react-native/IconExternalLink";
58import IconEyeOff from "@tabler/icons-react-native/IconEyeOff";58import IconEyeOff from "@tabler/icons-react-native/IconEyeOff";
59import IconEye from "@tabler/icons-react-native/IconEye";59import IconEye from "@tabler/icons-react-native/IconEye";
60import IconFileText from "@tabler/icons-react-native/IconFileText";60import IconFileText from "@tabler/icons-react-native/IconFileText";
61import IconFileDiff from "@tabler/icons-react-native/IconFileDiff";
61import IconFilter from "@tabler/icons-react-native/IconFilter";62import IconFilter from "@tabler/icons-react-native/IconFilter";
62import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled";63import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled";
63import IconFolder from "@tabler/icons-react-native/IconFolder";64import IconFolder from "@tabler/icons-react-native/IconFolder";
...@@ -188,6 +189,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {...@@ -188,6 +189,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
188 doc: IconFileText,189 doc: IconFileText,
189 "doc.on.doc": IconCopy,190 "doc.on.doc": IconCopy,
190 "doc.text": IconFileText,191 "doc.text": IconFileText,
192 "file.diff": IconFileDiff,
191 ellipsis: IconDots,193 ellipsis: IconDots,
192 moon: IconMoon,194 moon: IconMoon,
193 "ellipsis.circle": IconDotsCircleHorizontal,195 "ellipsis.circle": IconDotsCircleHorizontal,
...@@ -271,10 +273,16 @@ const ANDROID_ICON_BY_SF_SYMBOL = {...@@ -271,10 +273,16 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
271 xmark: IconX,273 xmark: IconX,
272 "xmark.circle.fill": IconCircleXFilled,274 "xmark.circle.fill": IconCircleXFilled,
273} satisfies Partial<275} 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 >
275>;280>;
276const SF_ICON_LOOKUP: Partial<281const 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 >
278> = ANDROID_ICON_BY_SF_SYMBOL;286> = ANDROID_ICON_BY_SF_SYMBOL;
279287
280// Callers can pass `{ ios, android }` names where `android` is a Material288// 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) {...@@ -92,8 +92,6 @@ export function HomeHeader(props: HomeHeaderProps) {
92 [props],92 [props],
93 );93 );
9494
95 if (props.usesWorkspaceToolbar) return null;
96
97 return (95 return (
98 <>96 <>
99 <NativeStackScreenOptions options={{ headerShown: false }} />97 <NativeStackScreenOptions options={{ headerShown: false }} />
apps/mobile/src/features/home/HomeRouteScreen.tsx+2-2
...@@ -209,7 +209,7 @@ export function HomeRouteScreen() {...@@ -209,7 +209,7 @@ export function HomeRouteScreen() {
209209
210 return (210 return (
211 <AndroidHomeFabLayout211 <AndroidHomeFabLayout
212 disabled={workspace !== null}212 disabled={workspace !== null && Platform.OS !== "android"}
213 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}213 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
214 >214 >
215 <>215 <>
...@@ -230,7 +230,7 @@ export function HomeRouteScreen() {...@@ -230,7 +230,7 @@ export function HomeRouteScreen() {
230 params: { screen: "SettingsEnvironments" },230 params: { screen: "SettingsEnvironments" },
231 }),231 }),
232 }),232 }),
233 headerShown: true,233 headerShown: Platform.OS !== "android",
234 }}234 }}
235 />235 />
236 )}236 )}
apps/mobile/src/features/home/HomeScreen.tsx+1-1
...@@ -983,7 +983,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -983,7 +983,7 @@ export function HomeScreen(props: HomeScreenProps) {
983 const v2ListHeader = (983 const v2ListHeader = (
984 <>984 <>
985 {listHeader}985 {listHeader}
986 {workspace ? (986 {Platform.OS === "ios" && workspace ? (
987 <ThreadListFilters987 <ThreadListFilters
988 environments={props.environments}988 environments={props.environments}
989 projects={projectScopes.map((scope) => ({ key: scope.key, label: scope.title }))}989 projects={projectScopes.map((scope) => ({ key: scope.key, label: scope.title }))}
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx+20-3
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react";1import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react";
2import { useNavigation, useNavigationState } from "@react-navigation/native";
3import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
2import {4import {
3 BackHandler,5 BackHandler,
4 Keyboard,6 Keyboard,
...@@ -35,6 +37,12 @@ export function MaterialThreadListToolbar(props: {...@@ -35,6 +37,12 @@ export function MaterialThreadListToolbar(props: {
35 readonly onRequestVisibility?: () => void;37 readonly onRequestVisibility?: () => void;
36}) {38}) {
37 const insets = useSafeAreaInsets();39 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");
38 const { fabSize } = useAndroidControlSizing();46 const { fabSize } = useAndroidControlSizing();
39 const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout();47 const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout();
40 const { state } = useWorkspaceState();48 const { state } = useWorkspaceState();
...@@ -57,13 +65,22 @@ export function MaterialThreadListToolbar(props: {...@@ -57,13 +65,22 @@ export function MaterialThreadListToolbar(props: {
57 }, [onSearchQueryChange]);65 }, [onSearchQueryChange]);
5866
59 useEffect(() => {67 useEffect(() => {
60 if (!searching) return;68 if (!searching || !listFocused) return;
61 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {69 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();
63 return true;80 return true;
64 });81 });
65 return () => subscription.remove();82 return () => subscription.remove();
66 }, [closeSearch, searching]);83 }, [closeSearch, searching, listFocused, navigation, isTabActive]);
6784
68 const filterIcon = props.filterCustomized85 const filterIcon = props.filterCustomized
69 ? "line.3.horizontal.decrease.circle.fill"86 ? "line.3.horizontal.decrease.circle.fill"
apps/mobile/src/features/home/home-thread-navigation.test.ts+2
...@@ -19,6 +19,8 @@ function loadRouters() {...@@ -19,6 +19,8 @@ function loadRouters() {
19 };19 };
20}20}
2121
22vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Keyboard: { dismiss: vi.fn() } }));
23
22vi.mock("@react-navigation/native", () => {24vi.mock("@react-navigation/native", () => {
23 const { CommonActions, StackActions } = loadRouters();25 const { CommonActions, StackActions } = loadRouters();
24 return { CommonActions, StackActions };26 return { CommonActions, StackActions };
apps/mobile/src/features/home/home-thread-navigation.ts+11-1
...@@ -7,6 +7,8 @@ import {...@@ -7,6 +7,8 @@ import {
7} from "@react-navigation/native";7} from "@react-navigation/native";
8import type { NativeStackNavigationProp } from "@react-navigation/native-stack";8import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
9import { useCallback, useEffect, useRef } from "react";9import { useCallback, useEffect, useRef } from "react";
10import { Platform } from "react-native";
11import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
1012
11type ThreadSelection = Pick<EnvironmentThreadShell, "environmentId" | "id">;13type ThreadSelection = Pick<EnvironmentThreadShell, "environmentId" | "id">;
1214
...@@ -34,6 +36,7 @@ export function useHomeThreadSelection() {...@@ -34,6 +36,7 @@ export function useHomeThreadSelection() {
34 const navigation =36 const navigation =
35 useNavigation<NativeStackNavigationProp<ReactNavigation.RootParamList, "Home">>();37 useNavigation<NativeStackNavigationProp<ReactNavigation.RootParamList, "Home">>();
36 const dismissingRouteKey = useRef<string | null>(null);38 const dismissingRouteKey = useRef<string | null>(null);
39 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
3740
38 useEffect(() => {41 useEffect(() => {
39 const clear = () => {42 const clear = () => {
...@@ -58,6 +61,13 @@ export function useHomeThreadSelection() {...@@ -58,6 +61,13 @@ export function useHomeThreadSelection() {
5861
59 return useCallback(62 return useCallback(
60 (thread: ThreadSelection) => {63 (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 }
61 navigation.dispatch((state) =>71 navigation.dispatch((state) =>
62 createHomeThreadNavigationAction({72 createHomeThreadNavigationAction({
63 state,73 state,
...@@ -66,6 +76,6 @@ export function useHomeThreadSelection() {...@@ -66,6 +76,6 @@ export function useHomeThreadSelection() {
66 }),76 }),
67 );77 );
68 },78 },
69 [navigation],79 [navigation, selectAndroidWorkspace],
70 );80 );
71}81}
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+133-29
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24} from "react";24} from "react";
25import { Platform, useWindowDimensions, View } from "react-native";25import { Platform, useWindowDimensions, View } from "react-native";
26import Animated, {26import Animated, {
27 runOnJS,
27 useAnimatedStyle,28 useAnimatedStyle,
28 useDerivedValue,29 useDerivedValue,
29 useSharedValue,30 useSharedValue,
...@@ -55,6 +56,13 @@ import {...@@ -55,6 +56,13 @@ import {
55 useHardwareKeyboardCommand,56 useHardwareKeyboardCommand,
56} from "../keyboard/hardwareKeyboardCommands";57} from "../keyboard/hardwareKeyboardCommands";
57import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";58import { 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";
58import { HomeListOptionsProvider } from "../home/home-list-options";66import { HomeListOptionsProvider } from "../home/home-list-options";
59import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";67import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
60import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";68import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
...@@ -243,7 +251,8 @@ function AdaptiveWorkspaceLayoutContent(...@@ -243,7 +251,8 @@ function AdaptiveWorkspaceLayoutContent(
243 const nativeWorkspace = use(NativeWorkspaceModeContext);251 const nativeWorkspace = use(NativeWorkspaceModeContext);
244 const windowDimensions = useWindowDimensions();252 const windowDimensions = useWindowDimensions();
245 const nativeMetrics = useNativeLayoutMetrics();253 const nativeMetrics = useNativeLayoutMetrics();
246 const { width, height } = nativeMetrics ?? windowDimensions;254 const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions;
255 const { scale: androidControlScale } = useAndroidControlSizing();
247 const pathname = props.pathname;256 const pathname = props.pathname;
248 const navigation = useNavigation();257 const navigation = useNavigation();
249 const activeRoleOwner = useRef<symbol | null>(null);258 const activeRoleOwner = useRef<symbol | null>(null);
...@@ -261,10 +270,18 @@ function AdaptiveWorkspaceLayoutContent(...@@ -261,10 +270,18 @@ function AdaptiveWorkspaceLayoutContent(
261 const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] =270 const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] =
262 useState<WorkspaceAuxiliaryPaneRole | null>(null);271 useState<WorkspaceAuxiliaryPaneRole | null>(null);
263 const baseLayout = useMemo(272 const baseLayout = useMemo(
264 () => deriveLayout({ width, height, nativeMetrics }),273 () => deriveLayout({ width: viewportWidth, height, nativeMetrics }),
265 [height, width, nativeMetrics],274 [height, viewportWidth, nativeMetrics],
266 );275 );
267 const layout = baseLayout;276 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;
268 // In split layouts the sidebar IS the thread list — it renders on every285 // In split layouts the sidebar IS the thread list — it renders on every
269 // route, including Home (which shows an empty-detail pane instead of the286 // route, including Home (which shows an empty-detail pane instead of the
270 // compact list).287 // compact list).
...@@ -478,22 +495,80 @@ function AdaptiveWorkspaceLayoutContent(...@@ -478,22 +495,80 @@ function AdaptiveWorkspaceLayoutContent(
478 [navigation],495 [navigation],
479 );496 );
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
481 const renderedSidebarWidth = useSharedValue(547 const renderedSidebarWidth = useSharedValue(
482 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0,548 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0,
483 );549 );
550 const renderedRailWidth = useSharedValue(navigationRailWidth);
484 useEffect(() => {551 useEffect(() => {
485 if (nativeWorkspace) return;552 if (nativeWorkspace) return;
486 const targetWidth = panes.primarySidebarVisible553 const targetWidth = panes.primarySidebarVisible
487 ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0)554 ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0)
488 : 0;555 : 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));
490 }, [560 }, [
491 nativeWorkspace,561 nativeWorkspace,
562 navigationRailWidth,
492 layout.listPaneWidth,563 layout.listPaneWidth,
493 layout.listPaneGap,564 layout.listPaneGap,
494 panes.primarySidebarVisible,565 panes.primarySidebarVisible,
495 renderedSidebarWidth,566 renderedSidebarWidth,
567 renderedRailWidth,
568 settleContentWidth,
569 contentRevision,
496 ]);570 ]);
571 const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value }));
497 const sidebarAnimatedStyle = useAnimatedStyle(() => ({572 const sidebarAnimatedStyle = useAnimatedStyle(() => ({
498 // Fading a UIKit glass ancestor can leave remounted header effects missing.573 // Fading a UIKit glass ancestor can leave remounted header effects missing.
499 // The clipped width already reveals and hides the iOS sidebar.574 // The clipped width already reveals and hides the iOS sidebar.
...@@ -507,24 +582,30 @@ function AdaptiveWorkspaceLayoutContent(...@@ -507,24 +582,30 @@ function AdaptiveWorkspaceLayoutContent(
507 // animating columns merely clip/reveal it over a matching background.582 // animating columns merely clip/reveal it over a matching background.
508 // Continuously re-wrapping the chat feed was the main source of dropped583 // Continuously re-wrapping the chat feed was the main source of dropped
509 // frames during sidebar/inspector transitions.584 // 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;
517 const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth);585 const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth);
518 const renderedContentWidth = useDerivedValue(() =>586 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 ),
520 );594 );
521595
596 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
522 const handleSelectThread = useCallback(597 const handleSelectThread = useCallback(
523 (thread: EnvironmentThreadShell) => {598 (thread: EnvironmentThreadShell) => {
524 const params = {599 const params = {
525 environmentId: String(thread.environmentId),600 environmentId: String(thread.environmentId),
526 threadId: String(thread.id),601 threadId: String(thread.id),
527 };602 };
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 }
528 const navigationAction = resolveThreadSelectionNavigationAction({609 const navigationAction = resolveThreadSelectionNavigationAction({
529 usesSplitView: layout.usesSplitView,610 usesSplitView: layout.usesSplitView,
530 pathname,611 pathname,
...@@ -556,7 +637,14 @@ function AdaptiveWorkspaceLayoutContent(...@@ -556,7 +637,14 @@ function AdaptiveWorkspaceLayoutContent(
556 }637 }
557 navigation.navigate("Thread", params);638 navigation.navigate("Thread", params);
558 },639 },
559 [layout.usesSplitView, pathname, navigation, selectedThreadKey, props.workspaceRouteKey],640 [
641 layout.usesSplitView,
642 pathname,
643 navigation,
644 selectedThreadKey,
645 props.workspaceRouteKey,
646 selectAndroidWorkspace,
647 ],
560 );648 );
561649
562 const contextValue = useMemo(650 const contextValue = useMemo(
...@@ -606,7 +694,21 @@ function AdaptiveWorkspaceLayoutContent(...@@ -606,7 +694,21 @@ function AdaptiveWorkspaceLayoutContent(
606 return (694 return (
607 <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}>695 <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}>
608 <AdaptiveWorkspaceContext.Provider value={contextValue}>696 <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}
610 {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? (712 {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? (
611 <Animated.View713 <Animated.View
612 className="self-stretch overflow-hidden"714 className="self-stretch overflow-hidden"
...@@ -628,21 +730,23 @@ function AdaptiveWorkspaceLayoutContent(...@@ -628,21 +730,23 @@ function AdaptiveWorkspaceLayoutContent(
628 />730 />
629 )}731 )}
630 >732 >
631 <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>733 <AndroidWorkspaceSidebar onRequestVisibility={revealPrimarySidebar}>
632 <ThreadNavigationSidebar734 <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>
633 width={layout.listPaneWidth}735 <ThreadNavigationSidebar
634 visible={panes.primarySidebarVisible}736 width={layout.listPaneWidth}
635 onRequestVisibility={revealPrimarySidebar}737 visible={panes.primarySidebarVisible}
636 selectedThreadKey={selectedThreadKey}738 onRequestVisibility={revealPrimarySidebar}
637 onOpenSettings={handleOpenSettings}739 selectedThreadKey={selectedThreadKey}
638 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}740 onOpenSettings={handleOpenSettings}
639 onNewThreadInProject={handleNewThreadInProject}741 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
640 onNewThreadOnBranch={handleNewThreadOnBranch}742 onNewThreadInProject={handleNewThreadInProject}
641 onSelectThread={handleSelectThread}743 onNewThreadOnBranch={handleNewThreadOnBranch}
642 onSearchQueryChange={setPrimarySidebarSearchQuery}744 onSelectThread={handleSelectThread}
643 searchQuery={primarySidebarSearchQuery}745 onSearchQueryChange={setPrimarySidebarSearchQuery}
644 />746 searchQuery={primarySidebarSearchQuery}
645 </AndroidHomeFabLayout>747 />
748 </AndroidHomeFabLayout>
749 </AndroidWorkspaceSidebar>
646 </RenderErrorBoundary>750 </RenderErrorBoundary>
647 </View>751 </View>
648 </Animated.View>752 </Animated.View>
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+1-1
...@@ -10,7 +10,7 @@ export function AndroidWorkspaceSidebarButton() {...@@ -10,7 +10,7 @@ export function AndroidWorkspaceSidebarButton() {
1010
11 return (11 return (
12 <AndroidHeaderIconButton12 <AndroidHeaderIconButton
13 accessibilityLabel={panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar"}13 accessibilityLabel={panes.primarySidebarVisible ? "Maximize content" : "Show sidebar"}
14 icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"}14 icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"}
15 onPress={togglePrimarySidebar}15 onPress={togglePrimarySidebar}
16 />16 />
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";...@@ -9,6 +9,7 @@ import { AppText as Text } from "../../components/AppText";
9import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";9import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
10import { MaterialButton } from "../../components/MaterialButton";10import { MaterialButton } from "../../components/MaterialButton";
11import { MaterialIconButton } from "../../components/MaterialIconButton";11import { MaterialIconButton } from "../../components/MaterialIconButton";
12import { MaterialFloatingToolbar } from "./MaterialFloatingToolbar";
12import { ControlPillMenu } from "../../components/ControlPillMenu";13import { ControlPillMenu } from "../../components/ControlPillMenu";
13import { NativeHeaderToolbar } from "../../native/StackHeader";14import { NativeHeaderToolbar } from "../../native/StackHeader";
14import {15import {
...@@ -110,6 +111,7 @@ export function PullRequestActionToolbar(props: {...@@ -110,6 +111,7 @@ export function PullRequestActionToolbar(props: {
110 readonly params: PullRequestParams;111 readonly params: PullRequestParams;
111 readonly filePath?: string;112 readonly filePath?: string;
112 readonly onOpenFiles?: () => void;113 readonly onOpenFiles?: () => void;
114 readonly onHeightChange?: (height: number) => void;
113}) {115}) {
114 const navigation = useNavigation();116 const navigation = useNavigation();
115 const { draft } = usePullRequestReviewDraft(props.params);117 const { draft } = usePullRequestReviewDraft(props.params);
...@@ -156,52 +158,57 @@ export function PullRequestActionToolbar(props: {...@@ -156,52 +158,57 @@ export function PullRequestActionToolbar(props: {
156 const primary = (158 const primary = (
157 <MaterialButton159 <MaterialButton
158 label={primaryLabel}160 label={primaryLabel}
159 tone="contrast"161 tone="primary"
160 fullWidth
161 disabled={primaryDisabled}162 disabled={primaryDisabled}
162 onPress={primaryPress}163 onPress={primaryPress}
163 />164 />
164 );165 );
165 return (166 return (
166 <View167 <View
167 className="flex-row items-center justify-between gap-3 border-t border-border-subtle bg-screen px-4 pt-2"168 pointerEvents="box-none"
168 style={{ paddingBottom: Math.max(insets.bottom, 8) }}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)}
169 >172 >
170 {showAgentControls ? (173 <MaterialFloatingToolbar>
171 <ControlPillMenu174 <View className="flex-row items-center gap-2">
172 title="AI helpers"175 {showAgentControls ? (
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 ? (
188 <ControlPillMenu176 <ControlPillMenu
189 title="Open thread"177 title="AI helpers"
190 actions={threadMenuItems}178 actions={agentActions.map((action) => agent.menuAction(action.task))}
191 onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)}179 onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)}
192 >180 >
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>
194 </ControlPillMenu>188 </ControlPillMenu>
195 ) : (189 ) : null}
196 primary190 {showPrimary ? (
197 )}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 />
198 </View>210 </View>
199 ) : null}211 </MaterialFloatingToolbar>
200 <MaterialIconButton
201 icon="doc.on.doc"
202 accessibilityLabel="Changed files"
203 onPress={openFiles}
204 />
205 </View>212 </View>
206 );213 );
207 }214 }
...@@ -299,12 +306,13 @@ export function PullRequestActionToolbar(props: {...@@ -299,12 +306,13 @@ export function PullRequestActionToolbar(props: {
299 )}306 )}
300 </NativeHeaderToolbar.Menu>307 </NativeHeaderToolbar.Menu>
301 ) : null;308 ) : null;
309 // Tabler file-diff geometry, rasterized at 1x/2x/3x for UIKit template images.
302 const filesControl = (310 const filesControl = (
303 <NativeHeaderToolbar.Button311 <NativeHeaderToolbar.Button
304 identifier="workspace-trailing-action"312 identifier="workspace-trailing-action"
305 width={controlSize}313 width={controlSize}
306 height={controlSize}314 height={controlSize}
307 icon="document.on.document"315 icon={{ type: "image", source: require("../../../assets/icons/file-diff.png") }}
308 accessibilityLabel="Changed files"316 accessibilityLabel="Changed files"
309 onPress={openFiles}317 onPress={openFiles}
310 separateBackground318 separateBackground
apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx+65-36
...@@ -3,10 +3,12 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"...@@ -3,10 +3,12 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"
3import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts";3import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts";
4import { AsyncResult } from "effect/reactivity";4import { AsyncResult } from "effect/reactivity";
5import { useRef, useState } from "react";5import { useRef, useState } from "react";
6import { ActivityIndicator, Alert, Pressable, View } from "react-native";6import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
7import { AppText as Text } from "../../components/AppText";7import { AppText as Text } from "../../components/AppText";
8import { SymbolView } from "../../components/AppSymbol";8import { SymbolView } from "../../components/AppSymbol";
9import { ControlPillMenu } from "../../components/ControlPillMenu";9import { ControlPillMenu } from "../../components/ControlPillMenu";
10import { MaterialButton } from "../../components/MaterialButton";
11import { MaterialIconButton } from "../../components/MaterialIconButton";
10import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";12import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
11import { composerPullRequests } from "../../state/pull-requests";13import { composerPullRequests } from "../../state/pull-requests";
12import { useAtomCommand } from "../../state/use-atom-command";14import { useAtomCommand } from "../../state/use-atom-command";
...@@ -164,33 +166,50 @@ export function PullRequestMergeControls(props: {...@@ -164,33 +166,50 @@ export function PullRequestMergeControls(props: {
164 return (166 return (
165 <View className="gap-2 border-t border-border-subtle p-3">167 <View className="gap-2 border-t border-border-subtle p-3">
166 <View168 <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 }
168 >174 >
169 <Pressable175 {Platform.OS === "android" ? (
170 accessibilityRole="button"176 <View className="min-w-0 flex-1">
171 accessibilityLabel={177 <MaterialButton
172 scope ? `${label}, ${scope.layers.length} stack layers through #${pr.number}` : label178 label={busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label}
173 }179 tone="primary"
174 accessibilityState={{ disabled: !canMerge, busy: busy || checkingStack }}180 fullWidth
175 disabled={!canMerge}181 disabled={!canMerge}
176 onPress={() => confirm("merge")}182 loading={busy || checkingStack}
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"183 onPress={() => confirm("merge")}
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"}
186 />184 />
187 )}185 </View>
188 <Text186 ) : (
189 className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-screen" : "text-foreground-muted"}`}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"
190 >196 >
191 {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label}197 {busy || checkingStack ? (
192 </Text>198 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
193 </Pressable>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 )}
194 {methods.length > 0 || canAutoMerge ? (213 {methods.length > 0 || canAutoMerge ? (
195 <ControlPillMenu214 <ControlPillMenu
196 title="Merge options"215 title="Merge options"
...@@ -219,17 +238,27 @@ export function PullRequestMergeControls(props: {...@@ -219,17 +238,27 @@ export function PullRequestMergeControls(props: {
219 } else if (nativeEvent.event === autoAction && canAutoMerge) confirm(autoAction);238 } else if (nativeEvent.event === autoAction && canAutoMerge) confirm(autoAction);
220 }}239 }}
221 >240 >
222 <Pressable241 {Platform.OS === "android" ? (
223 accessibilityRole="button"242 <View pointerEvents="none">
224 accessibilityLabel="Merge options"243 <MaterialIconButton
225 className="min-h-11 w-11 items-center justify-center border-l border-border-subtle"244 accessibilityLabel="Merge options"
226 >245 icon="chevron.down"
227 <SymbolView246 variant="tonal"
228 name="chevron.down"247 />
229 size={13}248 </View>
230 tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"}249 ) : (
231 />250 <Pressable
232 </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 )}
233 </ControlPillMenu>262 </ControlPillMenu>
234 ) : null}263 ) : null}
235 </View>264 </View>
apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx+2-2
...@@ -4,7 +4,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"...@@ -4,7 +4,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"
4import type { PullRequestReviewVerdict } from "@t3tools/contracts";4import type { PullRequestReviewVerdict } from "@t3tools/contracts";
5import { useCallback, useRef, useState } from "react";5import { useCallback, useRef, useState } from "react";
6import { useFocusEffect } from "@react-navigation/native";6import { useFocusEffect } from "@react-navigation/native";
7import { Pressable, View } from "react-native";7import { Platform, Pressable, View } from "react-native";
8import { clearSubmittedReview } from "./pullRequestReviewDrafts";8import { clearSubmittedReview } from "./pullRequestReviewDrafts";
9import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft";9import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft";
10import { reviewPositionLabel } from "./pullRequestDiffComments";10import { reviewPositionLabel } from "./pullRequestDiffComments";
...@@ -137,7 +137,7 @@ export function PullRequestReviewSheet({...@@ -137,7 +137,7 @@ export function PullRequestReviewSheet({
137 : "Post comment"137 : "Post comment"
138 }138 }
139 fullWidth139 fullWidth
140 tone="contrast"140 tone={Platform.OS === "android" ? "primary" : "contrast"}
141 loading={submission.busy}141 loading={submission.busy}
142 disabled={!canSubmit || result.isPending || result.error !== null}142 disabled={!canSubmit || result.isPending || result.error !== null}
143 onPress={() => void perform()}143 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";...@@ -57,6 +57,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
5757
58import { AppText as Text } from "../../components/AppText";58import { AppText as Text } from "../../components/AppText";
59import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";59import { 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";
60import { EmptyState } from "../../components/EmptyState";64import { EmptyState } from "../../components/EmptyState";
61import { SegmentedControl } from "../../components/SegmentedControl";65import { SegmentedControl } from "../../components/SegmentedControl";
62import { SymbolView } from "../../components/AppSymbol";66import { SymbolView } from "../../components/AppSymbol";
...@@ -269,9 +273,18 @@ function PullRequestOverview(props: {...@@ -269,9 +273,18 @@ function PullRequestOverview(props: {
269 );273 );
270}274}
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
272export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestParams>) {282export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestParams>) {
283 const returnToList = useAndroidWorkspaceBack("pullRequests");
284 const { layout } = useAdaptiveWorkspaceLayout();
273 const navigation = useNavigation();285 const navigation = useNavigation();
274 const insets = useSafeAreaInsets();286 const insets = useSafeAreaInsets();
287 const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
275 const params = route.params;288 const params = route.params;
276 const connected = usePullRequestConnected(params.environmentId);289 const connected = usePullRequestConnected(params.environmentId);
277 const [section, setSection] = useState<"overview" | "activity" | "guide">("overview");290 const [section, setSection] = useState<"overview" | "activity" | "guide">("overview");
...@@ -356,7 +369,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP...@@ -356,7 +369,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
356 params={params}369 params={params}
357 value={pr?.provider === "github" ? pr.url : null}370 value={pr?.provider === "github" ? pr.url : null}
358 >371 >
359 <View className="flex-1 bg-screen">372 <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}>
360 <NativeStackScreenOptions373 <NativeStackScreenOptions
361 options={{374 options={{
362 title: `#${params.number}`,375 title: `#${params.number}`,
...@@ -369,7 +382,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP...@@ -369,7 +382,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
369 {Platform.OS === "android" ? (382 {Platform.OS === "android" ? (
370 <AndroidScreenHeader383 <AndroidScreenHeader
371 title={`#${params.number}${connected ? "" : " · Offline"}`}384 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}
373 actions={[388 actions={[
374 ...(permissions.verdicts.length389 ...(permissions.verdicts.length
375 ? [390 ? [
...@@ -442,7 +457,15 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP...@@ -442,7 +457,15 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
442 </NativeHeaderToolbar.Menu>457 </NativeHeaderToolbar.Menu>
443 </NativeHeaderToolbar>458 </NativeHeaderToolbar>
444 <View459 <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 }
446 onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)}469 onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)}
447 >470 >
448 {(["overview", "guide", "activity"] as const)471 {(["overview", "guide", "activity"] as const)
...@@ -499,7 +522,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP...@@ -499,7 +522,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
499 contentContainerStyle={{522 contentContainerStyle={{
500 paddingHorizontal: 16,523 paddingHorizontal: 16,
501 paddingTop: 8,524 paddingTop: 8,
502 paddingBottom: 24,525 paddingBottom: 24 + (Platform.OS === "android" ? toolbarHeight : 0),
503 // Short tabs must still be tall enough to preserve the collapsed header.526 // Short tabs must still be tall enough to preserve the collapsed header.
504 minHeight: Math.max(527 minHeight: Math.max(
505 0,528 0,
...@@ -621,7 +644,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP...@@ -621,7 +644,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
621 >644 >
622 <PullRequestIdentity pr={identity} params={params} />645 <PullRequestIdentity pr={identity} params={params} />
623 <SegmentedControl646 <SegmentedControl
624 className="bg-grouped-card"647 className={Platform.OS === "android" ? undefined : "bg-grouped-card"}
625 options={[648 options={[
626 { value: "overview", label: "Overview" },649 { value: "overview", label: "Overview" },
627 ...(guidesEnabled650 ...(guidesEnabled
...@@ -643,7 +666,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP...@@ -643,7 +666,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
643 </View>666 </View>
644 ))}667 ))}
645 </View>668 </View>
646 <PullRequestActionToolbar pr={pr} params={params} onOpenFiles={openFiles} />669 <PullRequestActionToolbar
670 pr={pr}
671 params={params}
672 onOpenFiles={openFiles}
673 onHeightChange={setToolbarHeight}
674 />
647 </View>675 </View>
648 </PullRequestMarkdownRepository>676 </PullRequestMarkdownRepository>
649 );677 );
...@@ -685,8 +713,10 @@ export function PullRequestFileRouteScreen({...@@ -685,8 +713,10 @@ export function PullRequestFileRouteScreen({
685 readonly focus?: GuideDiffFocus;713 readonly focus?: GuideDiffFocus;
686 }714 }
687>) {715>) {
716 const { layout } = useAdaptiveWorkspaceLayout();
688 const navigation = useNavigation();717 const navigation = useNavigation();
689 const insets = useSafeAreaInsets();718 const insets = useSafeAreaInsets();
719 const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
690 const commit = route.params.commit;720 const commit = route.params.commit;
691 const result = usePullRequestDiff(route.params);721 const result = usePullRequestDiff(route.params);
692 const filter = usePullRequestFileFilter(722 const filter = usePullRequestFileFilter(
...@@ -875,7 +905,7 @@ export function PullRequestFileRouteScreen({...@@ -875,7 +905,7 @@ export function PullRequestFileRouteScreen({
875 const seekingFile = !targetFile && result.nextCursor !== null;905 const seekingFile = !targetFile && result.nextCursor !== null;
876 const loadingDiff = (result.isPending && !data.rows.length) || (seekingFile && !result.error);906 const loadingDiff = (result.isPending && !data.rows.length) || (seekingFile && !result.error);
877 return (907 return (
878 <View className="flex-1 bg-screen">908 <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}>
879 <NativeStackScreenOptions909 <NativeStackScreenOptions
880 options={{910 options={{
881 title,911 title,
...@@ -1022,7 +1052,15 @@ export function PullRequestFileRouteScreen({...@@ -1022,7 +1052,15 @@ export function PullRequestFileRouteScreen({
1022 />1052 />
1023 </View>1053 </View>
1024 ) : null}1054 ) : 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 >
1026 {loadingDiff || result.error ? (1064 {loadingDiff || result.error ? (
1027 <View style={{ paddingTop: Platform.OS === "ios" && !staleDiff ? insets.top : 0 }}>1065 <View style={{ paddingTop: Platform.OS === "ios" && !staleDiff ? insets.top : 0 }}>
1028 <PullRequestReadStatus pending={loadingDiff} error={result.error} />1066 <PullRequestReadStatus pending={loadingDiff} error={result.error} />
...@@ -1097,7 +1135,7 @@ export function PullRequestFileRouteScreen({...@@ -1097,7 +1135,7 @@ export function PullRequestFileRouteScreen({
1097 rowHeight={nativeReviewDiffStyle.rowHeight}1135 rowHeight={nativeReviewDiffStyle.rowHeight}
1098 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}1136 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
1099 contentInsetTop={Platform.OS === "ios" ? (staleDiff ? 0 : insets.top) : undefined}1137 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}
1101 contentResetKey={`pull-request:${route.params.environmentId}:${route.params.repository}:${route.params.number}:${commit ?? "all"}`}1139 contentResetKey={`pull-request:${route.params.environmentId}:${route.params.repository}:${route.params.number}:${commit ?? "all"}`}
1102 />1140 />
1103 </View>1141 </View>
...@@ -1147,6 +1185,7 @@ export function PullRequestFileRouteScreen({...@@ -1147,6 +1185,7 @@ export function PullRequestFileRouteScreen({
1147 params={route.params}1185 params={route.params}
1148 filePath={visibleFile?.path}1186 filePath={visibleFile?.path}
1149 onOpenFiles={() => navigation.navigate("PullRequestFiles", route.params)}1187 onOpenFiles={() => navigation.navigate("PullRequestFiles", route.params)}
1188 onHeightChange={setToolbarHeight}
1150 />1189 />
1151 </View>1190 </View>
1152 );1191 );
apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx+91-35
...@@ -1,9 +1,12 @@...@@ -1,9 +1,12 @@
1import { useNavigation } from "@react-navigation/native";1import { useNavigation } from "@react-navigation/native";
2import type { ReactNode } from "react";2import type { ReactNode } from "react";
3import { ScrollView, View } from "react-native";3import { Platform, ScrollView, useWindowDimensions, View } from "react-native";
4import { KeyboardAvoidingView } from "react-native-keyboard-controller";4import { KeyboardAvoidingView } from "react-native-keyboard-controller";
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
6import { AppText as Text } from "../../components/AppText";6import { AppText as Text } from "../../components/AppText";
7import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
8import { MaterialIconButton } from "../../components/MaterialIconButton";
9import { MaterialScreenContent } from "../../components/MaterialScreenContent";
7import { ControlPill } from "../../components/ControlPill";10import { ControlPill } from "../../components/ControlPill";
8import { NativeStackScreenOptions } from "../../native/StackHeader";11import { NativeStackScreenOptions } from "../../native/StackHeader";
912
...@@ -17,44 +20,97 @@ export function PullRequestSheetContent(props: {...@@ -17,44 +20,97 @@ export function PullRequestSheetContent(props: {
17}) {20}) {
18 const navigation = useNavigation();21 const navigation = useNavigation();
19 const insets = useSafeAreaInsets();22 const insets = useSafeAreaInsets();
20 return (23 const { height: windowHeight } = useWindowDimensions();
21 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1 bg-sheet">24 const android = Platform.OS === "android";
22 <NativeStackScreenOptions options={{ gestureEnabled: !props.busy }} />25 const fitToContents = android && props.scrollable !== false;
23 <View className="flex-row items-center gap-2 px-4 pt-5 pb-5">26 const footer = props.footer ? (
24 <ControlPill27 <View
25 icon="xmark"28 className={
26 accessibilityLabel="Close"29 android && props.scrollable !== false ? "bg-sheet-solid pt-3" : "bg-sheet px-4 pt-3"
27 disabled={props.busy}30 }
28 onPress={() => navigation.goBack()}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 }
29 />52 />
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>
42 ) : (53 ) : (
43 <ScrollView54 <View className="flex-row items-center gap-2 px-4 pt-5 pb-5">
44 className="flex-1"55 <ControlPill
45 keyboardShouldPersistTaps="handled"56 icon="xmark"
46 contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 20 }}57 accessibilityLabel="Close"
47 >58 disabled={props.busy}
48 <View className="gap-4" pointerEvents={props.busy ? "none" : "auto"}>59 onPress={() => navigation.goBack()}
49 {props.children}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>
50 </View>66 </View>
51 </ScrollView>67 <View className="w-11" />
52 )}
53 {props.footer ? (
54 <View className="bg-sheet px-4 pt-3" style={{ paddingBottom: Math.max(insets.bottom, 16) }}>
55 {props.footer}
56 </View>68 </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}
58 </KeyboardAvoidingView>114 </KeyboardAvoidingView>
59 );115 );
60}116}
apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx+9-8
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { StackActions, useNavigation } from "@react-navigation/native";1import { StackActions, useNavigation } from "@react-navigation/native";
2import { useState } from "react";2import { useState } from "react";
3import { View } from "react-native";3import { Platform, View } from "react-native";
4import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
4import { AppText as Text } from "../../components/AppText";5import { AppText as Text } from "../../components/AppText";
5import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls";6import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls";
6import { pullRequestStatus } from "./pullRequestStatus";7import { pullRequestStatus } from "./pullRequestStatus";
...@@ -12,6 +13,7 @@ export function PullRequestStackCard(props: {...@@ -12,6 +13,7 @@ export function PullRequestStackCard(props: {
12 readonly state: PullRequestStackState;13 readonly state: PullRequestStackState;
13}) {14}) {
14 const navigation = useNavigation();15 const navigation = useNavigation();
16 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
15 const [expanded, setExpanded] = useState(false);17 const [expanded, setExpanded] = useState(false);
16 const stack = props.state.stack;18 const stack = props.state.stack;
17 if (!stack) {19 if (!stack) {
...@@ -62,13 +64,12 @@ export function PullRequestStackCard(props: {...@@ -62,13 +64,12 @@ export function PullRequestStackCard(props: {
62 onPress={64 onPress={
63 selected65 selected
64 ? undefined66 ? undefined
65 : () =>67 : () => {
66 navigation.dispatch(68 const params = { ...props.params, number: layer.number };
67 StackActions.push("PullRequest", {69 if (Platform.OS === "android")
68 ...props.params,70 selectAndroidWorkspace({ name: "PullRequest", params });
69 number: layer.number,71 else navigation.dispatch(StackActions.push("PullRequest", params));
70 }),72 }
71 )
72 }73 }
73 last={index === stack.layers.length - 1}74 last={index === stack.layers.length - 1}
74 />75 />
apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx+7-3
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36} from "./pullRequestList";36} from "./pullRequestList";
37import { useDebouncedValue } from "../../state/queries";37import { useDebouncedValue } from "../../state/queries";
38import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";38import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
39import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
3940
40function PullRequestScrollView(props: ScrollViewProps) {41function PullRequestScrollView(props: ScrollViewProps) {
41 const insets = useSafeAreaInsets();42 const insets = useSafeAreaInsets();
...@@ -77,19 +78,22 @@ export function PullRequestsScreen() {...@@ -77,19 +78,22 @@ export function PullRequestsScreen() {
77 const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)78 const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)
78 ? selectedEnvironmentId79 ? selectedEnvironmentId
79 : null;80 : null;
81 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
80 const selectPullRequest = useCallback(82 const selectPullRequest = useCallback(
81 (item: EnvironmentPullRequestListEntry) => {83 (item: EnvironmentPullRequestListEntry) => {
82 Keyboard.dismiss();84 Keyboard.dismiss();
83 navigation.navigate("PullRequest", {85 const params = {
84 environmentId: String(item.environmentId),86 environmentId: String(item.environmentId),
85 projectId: String(item.projectId),87 projectId: String(item.projectId),
86 repository: item.repository,88 repository: item.repository,
87 number: item.number,89 number: item.number,
88 host: item.host,90 host: item.host,
89 initialSummary: item,91 initialSummary: item,
90 });92 };
93 if (Platform.OS === "android") selectAndroidWorkspace({ name: "PullRequest", params });
94 else navigation.navigate("PullRequest", params);
91 },95 },
92 [navigation],96 [navigation, selectAndroidWorkspace],
93 );97 );
94 const controlsAtom = pullRequestListControlsAtom(environmentId ?? "");98 const controlsAtom = pullRequestListControlsAtom(environmentId ?? "");
95 const controls = useAtomValue(controlsAtom);99 const controls = useAtomValue(controlsAtom);
apps/mobile/src/features/pull-requests/usePullRequestListActions.ts+10-4
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16} from "./pullRequestActions";16} from "./pullRequestActions";
17import { scopedPullRequestListKey, type EnvironmentPullRequestListEntry } from "./pullRequestList";17import { scopedPullRequestListKey, type EnvironmentPullRequestListEntry } from "./pullRequestList";
18import { pullRequestTarget } from "./usePullRequestDiff";18import { pullRequestTarget } from "./usePullRequestDiff";
19import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
1920
20export function usePullRequestListActions(21export function usePullRequestListActions(
21 environmentIds: readonly EnvironmentId[],22 environmentIds: readonly EnvironmentId[],
...@@ -26,6 +27,7 @@ export function usePullRequestListActions(...@@ -26,6 +27,7 @@ export function usePullRequestListActions(
26) {27) {
27 const registry = use(RegistryContext);28 const registry = use(RegistryContext);
28 const navigation = useNavigation();29 const navigation = useNavigation();
30 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
29 const permissionKey = JSON.stringify(environmentIds);31 const permissionKey = JSON.stringify(environmentIds);
30 const permissionAtom = useMemo(() => {32 const permissionAtom = useMemo(() => {
31 const ids: readonly EnvironmentId[] = JSON.parse(permissionKey);33 const ids: readonly EnvironmentId[] = JSON.parse(permissionKey);
...@@ -74,7 +76,9 @@ export function usePullRequestListActions(...@@ -74,7 +76,9 @@ export function usePullRequestListActions(
74 !pr.viewerPermissions.requestReviewers76 !pr.viewerPermissions.requestReviewers
75 )77 )
76 throw new Error("Review requests aren't available for this pull request.");78 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));
78 return;82 return;
79 }83 }
80 if (84 if (
...@@ -102,12 +106,14 @@ export function usePullRequestListActions(...@@ -102,12 +106,14 @@ export function usePullRequestListActions(
102 setBusyKey(null);106 setBusyKey(null);
103 }107 }
104 },108 },
105 [registry, navigation, params, run, onChanged],109 [registry, navigation, params, run, onChanged, selectAndroidWorkspace],
106 );110 );
107 const select = useCallback(111 const select = useCallback(
108 (entry: EnvironmentPullRequestListEntry, action: string) => {112 (entry: EnvironmentPullRequestListEntry, action: string) => {
109 if (action === "open") {113 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));
111 return;117 return;
112 }118 }
113 if (action === "request-review") {119 if (action === "request-review") {
...@@ -143,7 +149,7 @@ export function usePullRequestListActions(...@@ -143,7 +149,7 @@ export function usePullRequestListActions(
143 },149 },
144 ]);150 ]);
145 },151 },
146 [navigation, params, perform],152 [navigation, params, perform, selectAndroidWorkspace],
147 );153 );
148 const actions = useCallback(154 const actions = useCallback(
149 (entry: EnvironmentPullRequestListEntry): ControlPillMenuProps["actions"] => [155 (entry: EnvironmentPullRequestListEntry): ControlPillMenuProps["actions"] => [
apps/mobile/src/features/pull-requests/usePullRequestThreads.ts+8-3
...@@ -11,7 +11,8 @@ import { formatComposerContextReference } from "@t3tools/shared/composerContextR...@@ -11,7 +11,8 @@ import { formatComposerContextReference } from "@t3tools/shared/composerContextR
11import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";11import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
12import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";12import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
13import { useRef, useState } from "react";13import { useRef, useState } from "react";
14import { Alert } from "react-native";14import { Alert, Platform } from "react-native";
15import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
15import { pullRequestComposerContext } from "../../lib/composerContext";16import { pullRequestComposerContext } from "../../lib/composerContext";
16import { scopedThreadKey } from "../../lib/scopedEntities";17import { scopedThreadKey } from "../../lib/scopedEntities";
17import { uuidv4 } from "../../lib/uuid";18import { uuidv4 } from "../../lib/uuid";
...@@ -70,10 +71,14 @@ export function usePullRequestThreads(...@@ -70,10 +71,14 @@ export function usePullRequestThreads(
70 const canAsk = !connected || canOperate;71 const canAsk = !connected || canOperate;
71 const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope);72 const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope);
72 const navigation = useNavigation();73 const navigation = useNavigation();
74 const selectAndroidWorkspace = useAndroidWorkspaceSelection();
73 const pending = useRef(false);75 const pending = useRef(false);
74 const previousPrompts = useRef(new Map<string, string>());76 const previousPrompts = useRef(new Map<string, string>());
75 const openThread = (thread: LinkedThread) =>77 const openThread = (thread: LinkedThread) => {
76 navigation.navigate("Thread", { environmentId: params.environmentId, threadId: thread.id });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 };
77 const [busy, setBusy] = useState(false);82 const [busy, setBusy] = useState(false);
78 const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => {83 const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => {
79 if (!pr || !canAsk || pending.current) return;84 if (!pr || !canAsk || pending.current) return;
apps/mobile/src/features/threads/ThreadHeader.tsx+3-12
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { StackActions, useNavigation } from "@react-navigation/native";
2import { useMemo } from "react";1import { useMemo } from "react";
3import { ScreenHeader } from "../../components/ScreenHeader";2import { ScreenHeader } from "../../components/ScreenHeader";
4import { ScreenHeaderButton } from "../../components/ScreenHeaderButton";3import { ScreenHeaderButton } from "../../components/ScreenHeaderButton";
...@@ -6,6 +5,7 @@ import type { ScreenHeaderAction } from "../../components/ScreenHeader.types";...@@ -6,6 +5,7 @@ import type { ScreenHeaderAction } from "../../components/ScreenHeader.types";
6import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";5import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
7import type { ThreadInspectorMode } from "./thread-inspector-content-stack";6import type { ThreadInspectorMode } from "./thread-inspector-content-stack";
8import { useThreadHeaderOptions } from "./useThreadHeaderOptions";7import { useThreadHeaderOptions } from "./useThreadHeaderOptions";
8import { useWorkspaceListNavigation } from "../workspace/useAndroidWorkspaceNavigation";
99
10export function ThreadHeader(10export function ThreadHeader(
11 props: Parameters<typeof useThreadHeaderOptions>[0] & {11 props: Parameters<typeof useThreadHeaderOptions>[0] & {
...@@ -18,7 +18,7 @@ export function ThreadHeader(...@@ -18,7 +18,7 @@ export function ThreadHeader(
18 readonly onOpenFilesInspector: () => void;18 readonly onOpenFilesInspector: () => void;
19 },19 },
20) {20) {
21 const navigation = useNavigation();21 const returnToList = useWorkspaceListNavigation("threads");
22 const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();22 const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
23 const { onOpenTerminal, onMergeBack } = props.gitControls;23 const { onOpenTerminal, onMergeBack } = props.gitControls;
24 const native = useThreadHeaderOptions(props);24 const native = useThreadHeaderOptions(props);
...@@ -96,16 +96,7 @@ export function ThreadHeader(...@@ -96,16 +96,7 @@ export function ThreadHeader(
96 />96 />
97 ) : null97 ) : null
98 }98 }
99 onBack={99 onBack={layout.usesSplitView ? undefined : returnToList}
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 }
109 actions={androidHeaderActions}100 actions={androidHeaderActions}
110 hideBottomBorder101 hideBottomBorder
111 />102 />
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+7-9
...@@ -87,6 +87,10 @@ import {...@@ -87,6 +87,10 @@ import {
87} from "./thread-inspector-content-stack";87} from "./thread-inspector-content-stack";
88import { threadRouteIsHydrating } from "./thread-route-hydration";88import { threadRouteIsHydrating } from "./thread-route-hydration";
89import { ThreadHeader } from "./ThreadHeader";89import { ThreadHeader } from "./ThreadHeader";
90import {
91 useAndroidWorkspaceBack,
92 useWorkspaceListNavigation,
93} from "../workspace/useAndroidWorkspaceNavigation";
9094
91interface ThreadInspectorSelection {95interface ThreadInspectorSelection {
92 readonly routeThreadIdentity: string | null;96 readonly routeThreadIdentity: string | null;
...@@ -114,7 +118,7 @@ function ThreadPane(props: { readonly children: ReactNode }) {...@@ -114,7 +118,7 @@ function ThreadPane(props: { readonly children: ReactNode }) {
114}118}
115119
116function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) {120function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) {
117 const navigation = useNavigation();121 const returnToList = useWorkspaceListNavigation("threads");
118 const { layout } = useAdaptiveWorkspaceLayout();122 const { layout } = useAdaptiveWorkspaceLayout();
119 const { themeVariables } = useAppearancePreferences();123 const { themeVariables } = useAppearancePreferences();
120 if (Platform.OS !== "android") return props.children;124 if (Platform.OS !== "android") return props.children;
...@@ -124,14 +128,7 @@ function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) {...@@ -124,14 +128,7 @@ function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) {
124 <ScreenHeader128 <ScreenHeader
125 title="Thread"129 title="Thread"
126 options={{ contentStyle: { backgroundColor: themeVariables["--color-header"] } }}130 options={{ contentStyle: { backgroundColor: themeVariables["--color-header"] } }}
127 onBack={131 onBack={layout.usesSplitView ? undefined : returnToList}
128 layout.usesSplitView
129 ? undefined
130 : () => {
131 if (navigation.canGoBack()) navigation.goBack();
132 else navigation.dispatch(StackActions.replace("Home"));
133 }
134 }
135 hideBottomBorder132 hideBottomBorder
136 />133 />
137 <ThreadPane>{props.children}</ThreadPane>134 <ThreadPane>{props.children}</ThreadPane>
...@@ -190,6 +187,7 @@ function ThreadUnavailableScreen(props: {...@@ -190,6 +187,7 @@ function ThreadUnavailableScreen(props: {
190}187}
191188
192export function ThreadRouteScreen(props: ThreadRouteScreenProps) {189export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
190 useAndroidWorkspaceBack("threads");
193 const connectionsReady = useConnectionsReady();191 const connectionsReady = useConnectionsReady();
194 const { connectionState } = useRemoteConnectionStatus();192 const { connectionState } = useRemoteConnectionStatus();
195 const { selectedThread } = useThreadSelection();193 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 & {...@@ -49,10 +49,17 @@ type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {
49};49};
5050
51function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {51function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {
52 if (typeof icon !== "string") {52 if (typeof icon === "string") return { type: "sfSymbol", name: icon as never };
53 return undefined;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;
54 }61 }
55 return { type: "sfSymbol", name: icon as never };62 return undefined;
56}63}
5764
58type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode };65type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode };
...@@ -275,7 +282,7 @@ function NativeHeaderToolbarButton(_props: {...@@ -275,7 +282,7 @@ function NativeHeaderToolbarButton(_props: {
275 readonly accessibilityLabel?: string;282 readonly accessibilityLabel?: string;
276 readonly axisBehavior?: NativeStackHeaderAxisBehavior;283 readonly axisBehavior?: NativeStackHeaderAxisBehavior;
277 readonly disabled?: boolean;284 readonly disabled?: boolean;
278 readonly icon?: string;285 readonly icon?: string | Extract<NativeStackHeaderIcon, { type: "image" }>;
279 readonly label?: string;286 readonly label?: string;
280 readonly onPress?: () => void;287 readonly onPress?: () => void;
281 readonly separateBackground?: boolean;288 readonly separateBackground?: boolean;
apps/mobile/src/native/useOpenPullRequest.ts+10-1
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import { useNavigation, useRoute, StackActions } from "@react-navigation/native";1import { useNavigation, useRoute, StackActions } from "@react-navigation/native";
2import { nativePullRequestDestination } from "../lib/pullRequestNavigation";2import { nativePullRequestDestination } from "../lib/pullRequestNavigation";
3import { useCallback } from "react";3import { useCallback } from "react";
4import { Platform } from "react-native";
5import { CommonActions } from "@react-navigation/native";
6import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation";
4import { EnvironmentId, ThreadId } from "@t3tools/contracts";7import { EnvironmentId, ThreadId } from "@t3tools/contracts";
5import { useThreadShell } from "../state/entities";8import { useThreadShell } from "../state/entities";
6import { tryOpenExternalUrl } from "../lib/openExternalUrl";9import { tryOpenExternalUrl } from "../lib/openExternalUrl";
...@@ -42,7 +45,13 @@ export function useOpenPullRequest(options?: {...@@ -42,7 +45,13 @@ export function useOpenPullRequest(options?: {
42 : null,45 : null,
43 );46 );
44 if (!params) return tryOpenExternalUrl(url, "pull-request");47 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));
46 else navigation.navigate("PullRequest", params);55 else navigation.navigate("PullRequest", params);
47 return true;56 return true;
48 },57 },