| author | |
| committer | |
| log | 397a46123999ada6b9ddcbe85534631a241bb0ed |
| tree | f43751bd71c0d65c88dc263de6396aa561d27fa5 |
| parent | f79f37a637bda257faa06a04184e368f78650d3d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | buildscript { | |
| 2 | repositories { mavenCentral() } | |
| 3 | dependencies { | |
| 4 | classpath("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:${kotlinVersion}") | |
| 5 | } | |
| 6 | } | |
| 7 | ||
| 1 | 8 | apply plugin: 'com.android.library' |
| 2 | 9 | apply plugin: 'org.jetbrains.kotlin.android' |
| 10 | apply plugin: 'org.jetbrains.kotlin.plugin.compose' | |
| 3 | 11 | |
| 4 | 12 | group = 'com.t3tools.nativecontrols' |
| 5 | 13 | version = '0.0.0' |
| ... | ... | @@ -12,8 +20,11 @@ android { |
| 12 | 20 | minSdkVersion rootProject.ext.minSdkVersion |
| 13 | 21 | targetSdkVersion rootProject.ext.targetSdkVersion |
| 14 | 22 | } |
| 23 | buildFeatures { compose true } | |
| 15 | 24 | } |
| 16 | 25 | |
| 17 | 26 | dependencies { |
| 18 | 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 @@ |
| 1 | package expo.modules.t3nativecontrols | |
| 2 | ||
| 3 | import android.content.Context | |
| 4 | import android.view.ViewGroup | |
| 5 | import androidx.compose.foundation.layout.WindowInsets | |
| 6 | import androidx.compose.foundation.layout.Spacer | |
| 7 | import androidx.compose.foundation.layout.fillMaxSize | |
| 8 | import androidx.compose.material3.Icon | |
| 9 | import androidx.compose.material3.MaterialTheme | |
| 10 | import androidx.compose.material3.NavigationBar | |
| 11 | import androidx.compose.material3.NavigationBarItem | |
| 12 | import androidx.compose.material3.NavigationBarItemDefaults | |
| 13 | import androidx.compose.material3.NavigationRail | |
| 14 | import androidx.compose.material3.NavigationRailItem | |
| 15 | import androidx.compose.material3.NavigationRailItemDefaults | |
| 16 | import androidx.compose.material3.Text | |
| 17 | import androidx.compose.material3.darkColorScheme | |
| 18 | import androidx.compose.material3.lightColorScheme | |
| 19 | import androidx.compose.runtime.Composable | |
| 20 | import androidx.compose.runtime.getValue | |
| 21 | import androidx.compose.runtime.mutableStateOf | |
| 22 | import androidx.compose.runtime.setValue | |
| 23 | import androidx.compose.ui.Modifier | |
| 24 | import androidx.compose.ui.graphics.Color | |
| 25 | import androidx.compose.ui.platform.ComposeView | |
| 26 | import androidx.compose.ui.platform.ViewCompositionStrategy | |
| 27 | import androidx.compose.ui.res.painterResource | |
| 28 | import androidx.compose.ui.unit.sp | |
| 29 | import expo.modules.kotlin.AppContext | |
| 30 | import expo.modules.kotlin.modules.Module | |
| 31 | import expo.modules.kotlin.modules.ModuleDefinition | |
| 32 | import expo.modules.kotlin.viewevent.EventDispatcher | |
| 33 | import expo.modules.kotlin.views.ExpoView | |
| 34 | ||
| 35 | class 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. */ | |
| 63 | class 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 | 7 | "android": { |
| 8 | 8 | "modules": [ |
| 9 | 9 | "expo.modules.t3nativecontrols.T3NativeControlsModule", |
| 10 | "expo.modules.t3nativecontrols.T3WorkspaceNavigationModule", | |
| 10 | 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 | 41 | Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double -> |
| 42 | 42 | view.setContentWidth(contentWidth.toFloat()) |
| 43 | 43 | } |
| 44 | Prop("contentInsetBottom") { view: T3ReviewDiffView, value: Double -> | |
| 45 | view.setContentInsetBottom(value.toFloat()) | |
| 46 | } | |
| 44 | 47 | Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean -> |
| 45 | 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 | 143 | canvasView.contentWidthPx = max(width, dp(value).toInt()) |
| 144 | 144 | } |
| 145 | 145 | |
| 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 | 152 | fun setInitialRowIndex(value: Double) { |
| 147 | 153 | initialRowIndex = value.toInt().coerceAtLeast(0) |
| 148 | 154 | pendingInitialScroll = true |
| ... | ... | @@ -818,7 +824,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 818 | 824 | fun rowTop(index: Int): Int = rowOffsets[index.coerceIn(0, max(0, rowOffsets.size - 2))] |
| 819 | 825 | |
| 820 | 826 | fun setVerticalOffset(value: Int) { |
| 821 | val maxOffset = max(0, (rowOffsets.lastOrNull() ?: 0) - height) | |
| 827 | val maxOffset = maxVerticalOffset() | |
| 822 | 828 | val nextOffset = value.coerceIn(0, maxOffset) |
| 823 | 829 | if (verticalOffset == nextOffset) return |
| 824 | 830 | verticalOffset = nextOffset |
| ... | ... | @@ -832,7 +838,8 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 832 | 838 | |
| 833 | 839 | fun verticalOffset(): Int = verticalOffset |
| 834 | 840 | |
| 835 | fun maxVerticalOffset(): Int = max(0, (rowOffsets.lastOrNull() ?: 0) - height) | |
| 841 | fun maxVerticalOffset(): Int = | |
| 842 | max(0, (rowOffsets.lastOrNull() ?: 0) + paddingBottom - height) | |
| 836 | 843 | |
| 837 | 844 | fun setHorizontalOffset(value: Int) { |
| 838 | 845 | val nextOffset = value.coerceIn(0, maxHorizontalOffset()) |
| ... | ... | @@ -932,8 +939,9 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 932 | 939 | return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) } |
| 933 | 940 | } |
| 934 | 941 | } |
| 935 | val index = rowIndexAt(verticalOffset + y.toInt()) | |
| 936 | return rows.getOrNull(index)?.let { | |
| 942 | val contentY = verticalOffset + y.toInt() | |
| 943 | val index = rowIndexAt(contentY) | |
| 944 | return rows.getOrNull(index)?.takeIf { contentY in 0 until rowOffsets.last() }?.let { | |
| 937 | 945 | RowHit( |
| 938 | 946 | row = it, |
| 939 | 947 | top = rowOffsets[index] - verticalOffset, |
apps/mobile/src/Stack.tsx+21-12| ... | ... | @@ -31,6 +31,7 @@ import { useResolveClassNames } from "uniwind"; |
| 31 | 31 | import { AppText as Text } from "./components/AppText"; |
| 32 | 32 | import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; |
| 33 | 33 | import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen"; |
| 34 | import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider"; | |
| 34 | 35 | import { |
| 35 | 36 | PullRequestRouteScreen, |
| 36 | 37 | PullRequestFileRouteScreen, |
| ... | ... | @@ -170,6 +171,12 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { |
| 170 | 171 | unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, |
| 171 | 172 | }; |
| 172 | 173 | |
| 174 | // Android PR screens render their own Material toolbar. | |
| 175 | const PULL_REQUEST_HEADER_OPTIONS: AppScreenOptions = { | |
| 176 | ...GLASS_HEADER_OPTIONS, | |
| 177 | headerShown: Platform.OS !== "android", | |
| 178 | }; | |
| 179 | ||
| 173 | 180 | // SOLID: opaque sheet-colored header for surfaces that manage their own viewport. |
| 174 | 181 | const SOLID_HEADER_OPTIONS: AppScreenOptions = { |
| 175 | 182 | headerBackButtonDisplayMode: "minimal", |
| ... | ... | @@ -647,13 +654,15 @@ function RootStackLayout(props: { |
| 647 | 654 | <ThreadOutboxDrainWorker /> |
| 648 | 655 | <ShowcaseCaptureCoordinator pathname={pathname} /> |
| 649 | 656 | <ExistingThreadSettingsRouteProvider> |
| 650 | <AdaptiveWorkspaceLayout | |
| 651 | pathname={workspaceLocation.pathname} | |
| 652 | workspaceRouteKey={workspaceLocation.routeKey} | |
| 653 | > | |
| 654 | {props.children} | |
| 655 | <HardwareKeyboardCommandOverlay /> | |
| 656 | </AdaptiveWorkspaceLayout> | |
| 657 | <AndroidWorkspaceTabsProvider> | |
| 658 | <AdaptiveWorkspaceLayout | |
| 659 | pathname={workspaceLocation.pathname} | |
| 660 | workspaceRouteKey={workspaceLocation.routeKey} | |
| 661 | > | |
| 662 | {props.children} | |
| 663 | <HardwareKeyboardCommandOverlay /> | |
| 664 | </AdaptiveWorkspaceLayout> | |
| 665 | </AndroidWorkspaceTabsProvider> | |
| 657 | 666 | </ExistingThreadSettingsRouteProvider> |
| 658 | 667 | </HardwareKeyboardCommandProvider> |
| 659 | 668 | ); |
| ... | ... | @@ -721,15 +730,15 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 721 | 730 | path: "pull-requests/:environmentId/:projectId/:repository/:number", |
| 722 | 731 | parse: { number: Number }, |
| 723 | 732 | }, |
| 724 | options: GLASS_HEADER_OPTIONS, | |
| 733 | options: PULL_REQUEST_HEADER_OPTIONS, | |
| 725 | 734 | }), |
| 726 | 735 | PullRequestGuideStop: createNativeStackScreen({ |
| 727 | 736 | screen: PullRequestGuideStopScreen, |
| 728 | options: GLASS_HEADER_OPTIONS, | |
| 737 | options: PULL_REQUEST_HEADER_OPTIONS, | |
| 729 | 738 | }), |
| 730 | 739 | PullRequestFile: createNativeStackScreen({ |
| 731 | 740 | screen: PullRequestFileRouteScreen, |
| 732 | options: GLASS_HEADER_OPTIONS, | |
| 741 | options: PULL_REQUEST_HEADER_OPTIONS, | |
| 733 | 742 | }), |
| 734 | 743 | PullRequestFiles: createNativeStackScreen({ |
| 735 | 744 | screen: PullRequestFilesRouteScreen, |
| ... | ... | @@ -743,7 +752,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 743 | 752 | PullRequestReviewers: createNativeStackScreen({ |
| 744 | 753 | screen: PullRequestReviewersScreen, |
| 745 | 754 | options: { |
| 746 | ...GLASS_HEADER_OPTIONS, | |
| 755 | ...PULL_REQUEST_HEADER_OPTIONS, | |
| 747 | 756 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 748 | 757 | sheetAllowedDetents: [0.75, 0.92], |
| 749 | 758 | sheetGrabberVisible: true, |
| ... | ... | @@ -752,7 +761,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 752 | 761 | }), |
| 753 | 762 | PullRequestDiscussion: createNativeStackScreen({ |
| 754 | 763 | screen: PullRequestDiscussionScreen, |
| 755 | options: GLASS_HEADER_OPTIONS, | |
| 764 | options: PULL_REQUEST_HEADER_OPTIONS, | |
| 756 | 765 | }), |
| 757 | 766 | PullRequestInlineComment: createNativeStackScreen({ |
| 758 | 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 | 5 | import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft"; |
| 6 | 6 | import type { SFSymbol } from "expo-symbols"; |
| 7 | 7 | import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; |
| 8 | import IconFileDiff from "@tabler/icons-react-native/IconFileDiff"; | |
| 8 | 9 | import { SymbolView as ExpoSymbolView } from "expo-symbols"; |
| 9 | 10 | import { withUniwind } from "uniwind"; |
| 10 | 11 | import type { AppSymbolViewProps } from "./AppSymbol"; |
| ... | ... | @@ -12,7 +13,8 @@ import type { AppSymbolViewProps } from "./AppSymbol"; |
| 12 | 13 | export type { SFSymbol } from "expo-symbols"; |
| 13 | 14 | export type { AppSymbolName } from "./AppSymbol"; |
| 14 | 15 | |
| 15 | const GIT_ICONS = { | |
| 16 | const CUSTOM_ICONS = { | |
| 17 | "file.diff": IconFileDiff, | |
| 16 | 18 | "arrow.triangle.pull": IconGitPullRequest, |
| 17 | 19 | "arrow.triangle.merge": IconGitMerge, |
| 18 | 20 | "arrow.triangle.branch": IconGitBranch, |
| ... | ... | @@ -24,10 +26,10 @@ const GIT_ICONS = { |
| 24 | 26 | |
| 25 | 27 | function AppSymbolView(props: AppSymbolViewProps) { |
| 26 | 28 | const name = typeof props.name === "string" ? props.name : props.name.ios; |
| 27 | const GitIcon = GIT_ICONS[name as keyof typeof GIT_ICONS]; | |
| 28 | if (GitIcon) { | |
| 29 | const CustomIcon = CUSTOM_ICONS[name as keyof typeof CUSTOM_ICONS]; | |
| 30 | if (CustomIcon) { | |
| 29 | 31 | return ( |
| 30 | <GitIcon | |
| 32 | <CustomIcon | |
| 31 | 33 | accessibilityLabel={props.accessibilityLabel} |
| 32 | 34 | color={props.tintColor} |
| 33 | 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 | 58 | import IconEyeOff from "@tabler/icons-react-native/IconEyeOff"; |
| 59 | 59 | import IconEye from "@tabler/icons-react-native/IconEye"; |
| 60 | 60 | import IconFileText from "@tabler/icons-react-native/IconFileText"; |
| 61 | import IconFileDiff from "@tabler/icons-react-native/IconFileDiff"; | |
| 61 | 62 | import IconFilter from "@tabler/icons-react-native/IconFilter"; |
| 62 | 63 | import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled"; |
| 63 | 64 | import IconFolder from "@tabler/icons-react-native/IconFolder"; |
| ... | ... | @@ -188,6 +189,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { |
| 188 | 189 | doc: IconFileText, |
| 189 | 190 | "doc.on.doc": IconCopy, |
| 190 | 191 | "doc.text": IconFileText, |
| 192 | "file.diff": IconFileDiff, | |
| 191 | 193 | ellipsis: IconDots, |
| 192 | 194 | moon: IconMoon, |
| 193 | 195 | "ellipsis.circle": IconDotsCircleHorizontal, |
| ... | ... | @@ -271,10 +273,16 @@ const ANDROID_ICON_BY_SF_SYMBOL = { |
| 271 | 273 | xmark: IconX, |
| 272 | 274 | "xmark.circle.fill": IconCircleXFilled, |
| 273 | 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 | >; |
| 276 | 281 | const 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 | 286 | > = ANDROID_ICON_BY_SF_SYMBOL; |
| 279 | 287 | |
| 280 | 288 | // 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 | 92 | [props], |
| 93 | 93 | ); |
| 94 | 94 | |
| 95 | if (props.usesWorkspaceToolbar) return null; | |
| 96 | ||
| 97 | 95 | return ( |
| 98 | 96 | <> |
| 99 | 97 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
apps/mobile/src/features/home/HomeRouteScreen.tsx+2-2| ... | ... | @@ -209,7 +209,7 @@ export function HomeRouteScreen() { |
| 209 | 209 | |
| 210 | 210 | return ( |
| 211 | 211 | <AndroidHomeFabLayout |
| 212 | disabled={workspace !== null} | |
| 212 | disabled={workspace !== null && Platform.OS !== "android"} | |
| 213 | 213 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 214 | 214 | > |
| 215 | 215 | <> |
| ... | ... | @@ -230,7 +230,7 @@ export function HomeRouteScreen() { |
| 230 | 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 | 983 | const v2ListHeader = ( |
| 984 | 984 | <> |
| 985 | 985 | {listHeader} |
| 986 | {workspace ? ( | |
| 986 | {Platform.OS === "ios" && workspace ? ( | |
| 987 | 987 | <ThreadListFilters |
| 988 | 988 | environments={props.environments} |
| 989 | 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 | 1 | import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; |
| 2 | import { useNavigation, useNavigationState } from "@react-navigation/native"; | |
| 3 | import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; | |
| 2 | 4 | import { |
| 3 | 5 | BackHandler, |
| 4 | 6 | Keyboard, |
| ... | ... | @@ -35,6 +37,12 @@ export function MaterialThreadListToolbar(props: { |
| 35 | 37 | readonly onRequestVisibility?: () => void; |
| 36 | 38 | }) { |
| 37 | 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 | 46 | const { fabSize } = useAndroidControlSizing(); |
| 39 | 47 | const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout(); |
| 40 | 48 | const { state } = useWorkspaceState(); |
| ... | ... | @@ -57,13 +65,22 @@ export function MaterialThreadListToolbar(props: { |
| 57 | 65 | }, [onSearchQueryChange]); |
| 58 | 66 | |
| 59 | 67 | useEffect(() => { |
| 60 | if (!searching) return; | |
| 68 | if (!searching || !listFocused) return; | |
| 61 | 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 | 80 | return true; |
| 64 | 81 | }); |
| 65 | 82 | return () => subscription.remove(); |
| 66 | }, [closeSearch, searching]); | |
| 83 | }, [closeSearch, searching, listFocused, navigation, isTabActive]); | |
| 67 | 84 | |
| 68 | 85 | const filterIcon = props.filterCustomized |
| 69 | 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 | 19 | }; |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Keyboard: { dismiss: vi.fn() } })); | |
| 23 | ||
| 22 | 24 | vi.mock("@react-navigation/native", () => { |
| 23 | 25 | const { CommonActions, StackActions } = loadRouters(); |
| 24 | 26 | return { CommonActions, StackActions }; |
apps/mobile/src/features/home/home-thread-navigation.ts+11-1| ... | ... | @@ -7,6 +7,8 @@ import { |
| 7 | 7 | } from "@react-navigation/native"; |
| 8 | 8 | import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; |
| 9 | 9 | import { useCallback, useEffect, useRef } from "react"; |
| 10 | import { Platform } from "react-native"; | |
| 11 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | |
| 10 | 12 | |
| 11 | 13 | type ThreadSelection = Pick<EnvironmentThreadShell, "environmentId" | "id">; |
| 12 | 14 | |
| ... | ... | @@ -34,6 +36,7 @@ export function useHomeThreadSelection() { |
| 34 | 36 | const navigation = |
| 35 | 37 | useNavigation<NativeStackNavigationProp<ReactNavigation.RootParamList, "Home">>(); |
| 36 | 38 | const dismissingRouteKey = useRef<string | null>(null); |
| 39 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | |
| 37 | 40 | |
| 38 | 41 | useEffect(() => { |
| 39 | 42 | const clear = () => { |
| ... | ... | @@ -58,6 +61,13 @@ export function useHomeThreadSelection() { |
| 58 | 61 | |
| 59 | 62 | return useCallback( |
| 60 | 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 | 71 | navigation.dispatch((state) => |
| 62 | 72 | createHomeThreadNavigationAction({ |
| 63 | 73 | state, |
| ... | ... | @@ -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 | 24 | } from "react"; |
| 25 | 25 | import { Platform, useWindowDimensions, View } from "react-native"; |
| 26 | 26 | import Animated, { |
| 27 | runOnJS, | |
| 27 | 28 | useAnimatedStyle, |
| 28 | 29 | useDerivedValue, |
| 29 | 30 | useSharedValue, |
| ... | ... | @@ -55,6 +56,13 @@ import { |
| 55 | 56 | useHardwareKeyboardCommand, |
| 56 | 57 | } from "../keyboard/hardwareKeyboardCommands"; |
| 57 | 58 | import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; |
| 59 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | |
| 60 | import { | |
| 61 | AndroidWorkspaceNavigation, | |
| 62 | ANDROID_WORKSPACE_RAIL_WIDTH, | |
| 63 | } from "../workspace/AndroidWorkspaceNavigation"; | |
| 64 | import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; | |
| 65 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | |
| 58 | 66 | import { HomeListOptionsProvider } from "../home/home-list-options"; |
| 59 | 67 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; |
| 60 | 68 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; |
| ... | ... | @@ -243,7 +251,8 @@ function AdaptiveWorkspaceLayoutContent( |
| 243 | 251 | const nativeWorkspace = use(NativeWorkspaceModeContext); |
| 244 | 252 | const windowDimensions = useWindowDimensions(); |
| 245 | 253 | const nativeMetrics = useNativeLayoutMetrics(); |
| 246 | const { width, height } = nativeMetrics ?? windowDimensions; | |
| 254 | const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; | |
| 255 | const { scale: androidControlScale } = useAndroidControlSizing(); | |
| 247 | 256 | const pathname = props.pathname; |
| 248 | 257 | const navigation = useNavigation(); |
| 249 | 258 | const activeRoleOwner = useRef<symbol | null>(null); |
| ... | ... | @@ -261,10 +270,18 @@ function AdaptiveWorkspaceLayoutContent( |
| 261 | 270 | const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = |
| 262 | 271 | useState<WorkspaceAuxiliaryPaneRole | null>(null); |
| 263 | 272 | const baseLayout = useMemo( |
| 264 | () => deriveLayout({ width, height, nativeMetrics }), | |
| 265 | [height, width, nativeMetrics], | |
| 273 | () => deriveLayout({ width: viewportWidth, height, nativeMetrics }), | |
| 274 | [height, viewportWidth, nativeMetrics], | |
| 266 | 275 | ); |
| 267 | 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 | 285 | // In split layouts the sidebar IS the thread list — it renders on every |
| 269 | 286 | // route, including Home (which shows an empty-detail pane instead of the |
| 270 | 287 | // compact list). |
| ... | ... | @@ -478,22 +495,80 @@ function AdaptiveWorkspaceLayoutContent( |
| 478 | 495 | [navigation], |
| 479 | 496 | ); |
| 480 | 497 | |
| 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 | 547 | const renderedSidebarWidth = useSharedValue( |
| 482 | 548 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, |
| 483 | 549 | ); |
| 550 | const renderedRailWidth = useSharedValue(navigationRailWidth); | |
| 484 | 551 | useEffect(() => { |
| 485 | 552 | if (nativeWorkspace) return; |
| 486 | 553 | const targetWidth = panes.primarySidebarVisible |
| 487 | 554 | ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) |
| 488 | 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 | 561 | nativeWorkspace, |
| 562 | navigationRailWidth, | |
| 492 | 563 | layout.listPaneWidth, |
| 493 | 564 | layout.listPaneGap, |
| 494 | 565 | panes.primarySidebarVisible, |
| 495 | 566 | renderedSidebarWidth, |
| 567 | renderedRailWidth, | |
| 568 | settleContentWidth, | |
| 569 | contentRevision, | |
| 496 | 570 | ]); |
| 571 | const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value })); | |
| 497 | 572 | const sidebarAnimatedStyle = useAnimatedStyle(() => ({ |
| 498 | 573 | // Fading a UIKit glass ancestor can leave remounted header effects missing. |
| 499 | 574 | // The clipped width already reveals and hides the iOS sidebar. |
| ... | ... | @@ -507,24 +582,30 @@ function AdaptiveWorkspaceLayoutContent( |
| 507 | 582 | // animating columns merely clip/reveal it over a matching background. |
| 508 | 583 | // Continuously re-wrapping the chat feed was the main source of dropped |
| 509 | 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 | 585 | const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); |
| 518 | 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 | ); |
| 521 | 595 | |
| 596 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | |
| 522 | 597 | const handleSelectThread = useCallback( |
| 523 | 598 | (thread: EnvironmentThreadShell) => { |
| 524 | 599 | const params = { |
| 525 | 600 | environmentId: String(thread.environmentId), |
| 526 | 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 | 609 | const navigationAction = resolveThreadSelectionNavigationAction({ |
| 529 | 610 | usesSplitView: layout.usesSplitView, |
| 530 | 611 | pathname, |
| ... | ... | @@ -556,7 +637,14 @@ function AdaptiveWorkspaceLayoutContent( |
| 556 | 637 | } |
| 557 | 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 | ); |
| 561 | 649 | |
| 562 | 650 | const contextValue = useMemo( |
| ... | ... | @@ -606,7 +694,21 @@ function AdaptiveWorkspaceLayoutContent( |
| 606 | 694 | return ( |
| 607 | 695 | <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}> |
| 608 | 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 | 712 | {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( |
| 611 | 713 | <Animated.View |
| 612 | 714 | className="self-stretch overflow-hidden" |
| ... | ... | @@ -628,21 +730,23 @@ function AdaptiveWorkspaceLayoutContent( |
| 628 | 730 | /> |
| 629 | 731 | )} |
| 630 | 732 | > |
| 631 | <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}> | |
| 632 | <ThreadNavigationSidebar | |
| 633 | width={layout.listPaneWidth} | |
| 634 | visible={panes.primarySidebarVisible} | |
| 635 | onRequestVisibility={revealPrimarySidebar} | |
| 636 | selectedThreadKey={selectedThreadKey} | |
| 637 | onOpenSettings={handleOpenSettings} | |
| 638 | onOpenEnvironmentSettings={handleOpenEnvironmentSettings} | |
| 639 | onNewThreadInProject={handleNewThreadInProject} | |
| 640 | onNewThreadOnBranch={handleNewThreadOnBranch} | |
| 641 | onSelectThread={handleSelectThread} | |
| 642 | onSearchQueryChange={setPrimarySidebarSearchQuery} | |
| 643 | searchQuery={primarySidebarSearchQuery} | |
| 644 | /> | |
| 645 | </AndroidHomeFabLayout> | |
| 733 | <AndroidWorkspaceSidebar onRequestVisibility={revealPrimarySidebar}> | |
| 734 | <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}> | |
| 735 | <ThreadNavigationSidebar | |
| 736 | width={layout.listPaneWidth} | |
| 737 | visible={panes.primarySidebarVisible} | |
| 738 | onRequestVisibility={revealPrimarySidebar} | |
| 739 | selectedThreadKey={selectedThreadKey} | |
| 740 | onOpenSettings={handleOpenSettings} | |
| 741 | onOpenEnvironmentSettings={handleOpenEnvironmentSettings} | |
| 742 | onNewThreadInProject={handleNewThreadInProject} | |
| 743 | onNewThreadOnBranch={handleNewThreadOnBranch} | |
| 744 | onSelectThread={handleSelectThread} | |
| 745 | onSearchQueryChange={setPrimarySidebarSearchQuery} | |
| 746 | searchQuery={primarySidebarSearchQuery} | |
| 747 | /> | |
| 748 | </AndroidHomeFabLayout> | |
| 749 | </AndroidWorkspaceSidebar> | |
| 646 | 750 | </RenderErrorBoundary> |
| 647 | 751 | </View> |
| 648 | 752 | </Animated.View> |
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+1-1| ... | ... | @@ -10,7 +10,7 @@ export function AndroidWorkspaceSidebarButton() { |
| 10 | 10 | |
| 11 | 11 | return ( |
| 12 | 12 | <AndroidHeaderIconButton |
| 13 | accessibilityLabel={panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar"} | |
| 13 | accessibilityLabel={panes.primarySidebarVisible ? "Maximize content" : "Show sidebar"} | |
| 14 | 14 | icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"} |
| 15 | 15 | onPress={togglePrimarySidebar} |
| 16 | 16 | /> |
apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | import { HorizontalFloatingToolbar, Host, RNHostView } from "@expo/ui/jetpack-compose"; | |
| 2 | import type { ReactElement } from "react"; | |
| 3 | ||
| 4 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 5 | ||
| 6 | /** Native toolbar surface; hosted RN controls retain their existing menus and accessibility. */ | |
| 7 | export 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 @@ |
| 1 | import type { ReactElement } from "react"; | |
| 2 | ||
| 3 | export 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 @@ |
| 1 | import { useNavigation, useNavigationState } from "@react-navigation/native"; | |
| 2 | import { useCallback, useEffect, useRef } from "react"; | |
| 3 | import { BackHandler, Keyboard, View, type TextInputInstance } from "react-native"; | |
| 4 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 5 | import { CompactBrandTitle } from "../../components/CompactBrandTitle"; | |
| 6 | import { MaterialSearchField } from "../../components/MaterialSearchField"; | |
| 7 | import { WorkspaceConnectionTitle } from "../home/WorkspaceConnectionTitle"; | |
| 8 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | |
| 9 | import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; | |
| 10 | ||
| 11 | /** PR search shares the thread list's native Android header placement. */ | |
| 12 | export 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 | 9 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 10 | 10 | import { MaterialButton } from "../../components/MaterialButton"; |
| 11 | 11 | import { MaterialIconButton } from "../../components/MaterialIconButton"; |
| 12 | import { MaterialFloatingToolbar } from "./MaterialFloatingToolbar"; | |
| 12 | 13 | import { ControlPillMenu } from "../../components/ControlPillMenu"; |
| 13 | 14 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 14 | 15 | import { |
| ... | ... | @@ -110,6 +111,7 @@ export function PullRequestActionToolbar(props: { |
| 110 | 111 | readonly params: PullRequestParams; |
| 111 | 112 | readonly filePath?: string; |
| 112 | 113 | readonly onOpenFiles?: () => void; |
| 114 | readonly onHeightChange?: (height: number) => void; | |
| 113 | 115 | }) { |
| 114 | 116 | const navigation = useNavigation(); |
| 115 | 117 | const { draft } = usePullRequestReviewDraft(props.params); |
| ... | ... | @@ -156,52 +158,57 @@ export function PullRequestActionToolbar(props: { |
| 156 | 158 | const primary = ( |
| 157 | 159 | <MaterialButton |
| 158 | 160 | label={primaryLabel} |
| 159 | tone="contrast" | |
| 160 | fullWidth | |
| 161 | tone="primary" | |
| 161 | 162 | disabled={primaryDisabled} |
| 162 | 163 | onPress={primaryPress} |
| 163 | 164 | /> |
| 164 | 165 | ); |
| 165 | 166 | return ( |
| 166 | 167 | <View |
| 167 | className="flex-row items-center justify-between gap-3 border-t border-border-subtle bg-screen px-4 pt-2" | |
| 168 | style={{ paddingBottom: Math.max(insets.bottom, 8) }} | |
| 168 | pointerEvents="box-none" | |
| 169 | className="absolute inset-x-0 bottom-0 z-10 items-center px-4 pt-2" | |
| 170 | style={{ paddingBottom: insets.bottom + 16 }} | |
| 171 | onLayout={(event) => props.onHeightChange?.(event.nativeEvent.layout.height)} | |
| 169 | 172 | > |
| 170 | {showAgentControls ? ( | |
| 171 | <ControlPillMenu | |
| 172 | title="AI helpers" | |
| 173 | actions={agentActions.map((action) => agent.menuAction(action.task))} | |
| 174 | onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)} | |
| 175 | > | |
| 176 | <View pointerEvents="none"> | |
| 177 | <MaterialIconButton | |
| 178 | icon="sparkles" | |
| 179 | accessibilityLabel="PR agent actions" | |
| 180 | disabled={agent.disabled} | |
| 181 | /> | |
| 182 | </View> | |
| 183 | </ControlPillMenu> | |
| 184 | ) : null} | |
| 185 | {showPrimary ? ( | |
| 186 | <View className="min-w-0 flex-1"> | |
| 187 | {!pendingReview && chooseThread ? ( | |
| 173 | <MaterialFloatingToolbar> | |
| 174 | <View className="flex-row items-center gap-2"> | |
| 175 | {showAgentControls ? ( | |
| 188 | 176 | <ControlPillMenu |
| 189 | title="Open thread" | |
| 190 | actions={threadMenuItems} | |
| 177 | title="AI helpers" | |
| 178 | actions={agentActions.map((action) => agent.menuAction(action.task))} | |
| 191 | 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 | 188 | </ControlPillMenu> |
| 195 | ) : ( | |
| 196 | primary | |
| 197 | )} | |
| 189 | ) : null} | |
| 190 | {showPrimary ? ( | |
| 191 | <View className="min-w-0 shrink"> | |
| 192 | {!pendingReview && chooseThread ? ( | |
| 193 | <ControlPillMenu | |
| 194 | title="Open thread" | |
| 195 | actions={threadMenuItems} | |
| 196 | onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)} | |
| 197 | > | |
| 198 | <View pointerEvents="none">{primary}</View> | |
| 199 | </ControlPillMenu> | |
| 200 | ) : ( | |
| 201 | primary | |
| 202 | )} | |
| 203 | </View> | |
| 204 | ) : null} | |
| 205 | <MaterialIconButton | |
| 206 | icon="file.diff" | |
| 207 | accessibilityLabel="Changed files" | |
| 208 | onPress={openFiles} | |
| 209 | /> | |
| 198 | 210 | </View> |
| 199 | ) : null} | |
| 200 | <MaterialIconButton | |
| 201 | icon="doc.on.doc" | |
| 202 | accessibilityLabel="Changed files" | |
| 203 | onPress={openFiles} | |
| 204 | /> | |
| 211 | </MaterialFloatingToolbar> | |
| 205 | 212 | </View> |
| 206 | 213 | ); |
| 207 | 214 | } |
| ... | ... | @@ -299,12 +306,13 @@ export function PullRequestActionToolbar(props: { |
| 299 | 306 | )} |
| 300 | 307 | </NativeHeaderToolbar.Menu> |
| 301 | 308 | ) : null; |
| 309 | // Tabler file-diff geometry, rasterized at 1x/2x/3x for UIKit template images. | |
| 302 | 310 | const filesControl = ( |
| 303 | 311 | <NativeHeaderToolbar.Button |
| 304 | 312 | identifier="workspace-trailing-action" |
| 305 | 313 | width={controlSize} |
| 306 | 314 | height={controlSize} |
| 307 | icon="document.on.document" | |
| 315 | icon={{ type: "image", source: require("../../../assets/icons/file-diff.png") }} | |
| 308 | 316 | accessibilityLabel="Changed files" |
| 309 | 317 | onPress={openFiles} |
| 310 | 318 | separateBackground |
apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx+65-36| ... | ... | @@ -3,10 +3,12 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" |
| 3 | 3 | import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; |
| 4 | 4 | import { AsyncResult } from "effect/reactivity"; |
| 5 | 5 | import { useRef, useState } from "react"; |
| 6 | import { ActivityIndicator, Alert, Pressable, View } from "react-native"; | |
| 6 | import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; | |
| 7 | 7 | import { AppText as Text } from "../../components/AppText"; |
| 8 | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 9 | 9 | import { ControlPillMenu } from "../../components/ControlPillMenu"; |
| 10 | import { MaterialButton } from "../../components/MaterialButton"; | |
| 11 | import { MaterialIconButton } from "../../components/MaterialIconButton"; | |
| 10 | 12 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 11 | 13 | import { composerPullRequests } from "../../state/pull-requests"; |
| 12 | 14 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | ... | @@ -164,33 +166,50 @@ export function PullRequestMergeControls(props: { |
| 164 | 166 | return ( |
| 165 | 167 | <View className="gap-2 border-t border-border-subtle p-3"> |
| 166 | 168 | <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 | <Pressable | |
| 170 | accessibilityRole="button" | |
| 171 | accessibilityLabel={ | |
| 172 | scope ? `${label}, ${scope.layers.length} stack layers through #${pr.number}` : label | |
| 173 | } | |
| 174 | accessibilityState={{ disabled: !canMerge, busy: busy || checkingStack }} | |
| 175 | disabled={!canMerge} | |
| 176 | onPress={() => confirm("merge")} | |
| 177 | className="min-h-11 min-w-0 flex-1 flex-row items-center justify-center gap-2 px-3 py-2 active:opacity-70" | |
| 178 | > | |
| 179 | {busy || checkingStack ? ( | |
| 180 | <ActivityIndicator size="small" colorClassName="accent-icon-muted" /> | |
| 181 | ) : ( | |
| 182 | <SymbolView | |
| 183 | name="arrow.triangle.merge" | |
| 184 | size={16} | |
| 185 | tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"} | |
| 175 | {Platform.OS === "android" ? ( | |
| 176 | <View className="min-w-0 flex-1"> | |
| 177 | <MaterialButton | |
| 178 | label={busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label} | |
| 179 | tone="primary" | |
| 180 | fullWidth | |
| 181 | disabled={!canMerge} | |
| 182 | loading={busy || checkingStack} | |
| 183 | onPress={() => confirm("merge")} | |
| 186 | 184 | /> |
| 187 | )} | |
| 188 | <Text | |
| 189 | className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-screen" : "text-foreground-muted"}`} | |
| 185 | </View> | |
| 186 | ) : ( | |
| 187 | <Pressable | |
| 188 | accessibilityRole="button" | |
| 189 | accessibilityLabel={ | |
| 190 | scope ? `${label}, ${scope.layers.length} stack layers through #${pr.number}` : label | |
| 191 | } | |
| 192 | accessibilityState={{ disabled: !canMerge, busy: busy || checkingStack }} | |
| 193 | disabled={!canMerge} | |
| 194 | onPress={() => confirm("merge")} | |
| 195 | className="min-h-11 min-w-0 flex-1 flex-row items-center justify-center gap-2 px-3 py-2 active:opacity-70" | |
| 190 | 196 | > |
| 191 | {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label} | |
| 192 | </Text> | |
| 193 | </Pressable> | |
| 197 | {busy || checkingStack ? ( | |
| 198 | <ActivityIndicator size="small" colorClassName="accent-icon-muted" /> | |
| 199 | ) : ( | |
| 200 | <SymbolView | |
| 201 | name="arrow.triangle.merge" | |
| 202 | size={16} | |
| 203 | tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"} | |
| 204 | /> | |
| 205 | )} | |
| 206 | <Text | |
| 207 | className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-screen" : "text-foreground-muted"}`} | |
| 208 | > | |
| 209 | {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label} | |
| 210 | </Text> | |
| 211 | </Pressable> | |
| 212 | )} | |
| 194 | 213 | {methods.length > 0 || canAutoMerge ? ( |
| 195 | 214 | <ControlPillMenu |
| 196 | 215 | title="Merge options" |
| ... | ... | @@ -219,17 +238,27 @@ export function PullRequestMergeControls(props: { |
| 219 | 238 | } else if (nativeEvent.event === autoAction && canAutoMerge) confirm(autoAction); |
| 220 | 239 | }} |
| 221 | 240 | > |
| 222 | <Pressable | |
| 223 | accessibilityRole="button" | |
| 224 | accessibilityLabel="Merge options" | |
| 225 | className="min-h-11 w-11 items-center justify-center border-l border-border-subtle" | |
| 226 | > | |
| 227 | <SymbolView | |
| 228 | name="chevron.down" | |
| 229 | size={13} | |
| 230 | tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"} | |
| 231 | /> | |
| 232 | </Pressable> | |
| 241 | {Platform.OS === "android" ? ( | |
| 242 | <View pointerEvents="none"> | |
| 243 | <MaterialIconButton | |
| 244 | accessibilityLabel="Merge options" | |
| 245 | icon="chevron.down" | |
| 246 | variant="tonal" | |
| 247 | /> | |
| 248 | </View> | |
| 249 | ) : ( | |
| 250 | <Pressable | |
| 251 | accessibilityRole="button" | |
| 252 | accessibilityLabel="Merge options" | |
| 253 | className="min-h-11 w-11 items-center justify-center border-l border-border-subtle" | |
| 254 | > | |
| 255 | <SymbolView | |
| 256 | name="chevron.down" | |
| 257 | size={13} | |
| 258 | tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"} | |
| 259 | /> | |
| 260 | </Pressable> | |
| 261 | )} | |
| 233 | 262 | </ControlPillMenu> |
| 234 | 263 | ) : null} |
| 235 | 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 | 4 | import type { PullRequestReviewVerdict } from "@t3tools/contracts"; |
| 5 | 5 | import { useCallback, useRef, useState } from "react"; |
| 6 | 6 | import { useFocusEffect } from "@react-navigation/native"; |
| 7 | import { Pressable, View } from "react-native"; | |
| 7 | import { Platform, Pressable, View } from "react-native"; | |
| 8 | 8 | import { clearSubmittedReview } from "./pullRequestReviewDrafts"; |
| 9 | 9 | import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; |
| 10 | 10 | import { reviewPositionLabel } from "./pullRequestDiffComments"; |
| ... | ... | @@ -137,7 +137,7 @@ export function PullRequestReviewSheet({ |
| 137 | 137 | : "Post comment" |
| 138 | 138 | } |
| 139 | 139 | fullWidth |
| 140 | tone="contrast" | |
| 140 | tone={Platform.OS === "android" ? "primary" : "contrast"} | |
| 141 | 141 | loading={submission.busy} |
| 142 | 142 | disabled={!canSubmit || result.isPending || result.error !== null} |
| 143 | 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 | 57 | |
| 58 | 58 | import { AppText as Text } from "../../components/AppText"; |
| 59 | 59 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 60 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | |
| 61 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | |
| 62 | import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; | |
| 63 | import { useAndroidWorkspaceBack } from "../workspace/useAndroidWorkspaceNavigation"; | |
| 60 | 64 | import { EmptyState } from "../../components/EmptyState"; |
| 61 | 65 | import { SegmentedControl } from "../../components/SegmentedControl"; |
| 62 | 66 | import { SymbolView } from "../../components/AppSymbol"; |
| ... | ... | @@ -269,9 +273,18 @@ function PullRequestOverview(props: { |
| 269 | 273 | ); |
| 270 | 274 | } |
| 271 | 275 | |
| 276 | function 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 | ||
| 272 | 282 | export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestParams>) { |
| 283 | const returnToList = useAndroidWorkspaceBack("pullRequests"); | |
| 284 | const { layout } = useAdaptiveWorkspaceLayout(); | |
| 273 | 285 | const navigation = useNavigation(); |
| 274 | 286 | const insets = useSafeAreaInsets(); |
| 287 | const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom); | |
| 275 | 288 | const params = route.params; |
| 276 | 289 | const connected = usePullRequestConnected(params.environmentId); |
| 277 | 290 | const [section, setSection] = useState<"overview" | "activity" | "guide">("overview"); |
| ... | ... | @@ -356,7 +369,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 356 | 369 | params={params} |
| 357 | 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 | 373 | <NativeStackScreenOptions |
| 361 | 374 | options={{ |
| 362 | 375 | title: `#${params.number}`, |
| ... | ... | @@ -369,7 +382,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 369 | 382 | {Platform.OS === "android" ? ( |
| 370 | 383 | <AndroidScreenHeader |
| 371 | 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 | 388 | actions={[ |
| 374 | 389 | ...(permissions.verdicts.length |
| 375 | 390 | ? [ |
| ... | ... | @@ -442,7 +457,15 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 442 | 457 | </NativeHeaderToolbar.Menu> |
| 443 | 458 | </NativeHeaderToolbar> |
| 444 | 459 | <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 | 469 | onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)} |
| 447 | 470 | > |
| 448 | 471 | {(["overview", "guide", "activity"] as const) |
| ... | ... | @@ -499,7 +522,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 499 | 522 | contentContainerStyle={{ |
| 500 | 523 | paddingHorizontal: 16, |
| 501 | 524 | paddingTop: 8, |
| 502 | paddingBottom: 24, | |
| 525 | paddingBottom: 24 + (Platform.OS === "android" ? toolbarHeight : 0), | |
| 503 | 526 | // Short tabs must still be tall enough to preserve the collapsed header. |
| 504 | 527 | minHeight: Math.max( |
| 505 | 528 | 0, |
| ... | ... | @@ -621,7 +644,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 621 | 644 | > |
| 622 | 645 | <PullRequestIdentity pr={identity} params={params} /> |
| 623 | 646 | <SegmentedControl |
| 624 | className="bg-grouped-card" | |
| 647 | className={Platform.OS === "android" ? undefined : "bg-grouped-card"} | |
| 625 | 648 | options={[ |
| 626 | 649 | { value: "overview", label: "Overview" }, |
| 627 | 650 | ...(guidesEnabled |
| ... | ... | @@ -643,7 +666,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 643 | 666 | </View> |
| 644 | 667 | ))} |
| 645 | 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 | 675 | </View> |
| 648 | 676 | </PullRequestMarkdownRepository> |
| 649 | 677 | ); |
| ... | ... | @@ -685,8 +713,10 @@ export function PullRequestFileRouteScreen({ |
| 685 | 713 | readonly focus?: GuideDiffFocus; |
| 686 | 714 | } |
| 687 | 715 | >) { |
| 716 | const { layout } = useAdaptiveWorkspaceLayout(); | |
| 688 | 717 | const navigation = useNavigation(); |
| 689 | 718 | const insets = useSafeAreaInsets(); |
| 719 | const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom); | |
| 690 | 720 | const commit = route.params.commit; |
| 691 | 721 | const result = usePullRequestDiff(route.params); |
| 692 | 722 | const filter = usePullRequestFileFilter( |
| ... | ... | @@ -875,7 +905,7 @@ export function PullRequestFileRouteScreen({ |
| 875 | 905 | const seekingFile = !targetFile && result.nextCursor !== null; |
| 876 | 906 | const loadingDiff = (result.isPending && !data.rows.length) || (seekingFile && !result.error); |
| 877 | 907 | return ( |
| 878 | <View className="flex-1 bg-screen"> | |
| 908 | <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}> | |
| 879 | 909 | <NativeStackScreenOptions |
| 880 | 910 | options={{ |
| 881 | 911 | title, |
| ... | ... | @@ -1022,7 +1052,15 @@ export function PullRequestFileRouteScreen({ |
| 1022 | 1052 | /> |
| 1023 | 1053 | </View> |
| 1024 | 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 | 1064 | {loadingDiff || result.error ? ( |
| 1027 | 1065 | <View style={{ paddingTop: Platform.OS === "ios" && !staleDiff ? insets.top : 0 }}> |
| 1028 | 1066 | <PullRequestReadStatus pending={loadingDiff} error={result.error} /> |
| ... | ... | @@ -1097,7 +1135,7 @@ export function PullRequestFileRouteScreen({ |
| 1097 | 1135 | rowHeight={nativeReviewDiffStyle.rowHeight} |
| 1098 | 1136 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} |
| 1099 | 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 | 1139 | contentResetKey={`pull-request:${route.params.environmentId}:${route.params.repository}:${route.params.number}:${commit ?? "all"}`} |
| 1102 | 1140 | /> |
| 1103 | 1141 | </View> |
| ... | ... | @@ -1147,6 +1185,7 @@ export function PullRequestFileRouteScreen({ |
| 1147 | 1185 | params={route.params} |
| 1148 | 1186 | filePath={visibleFile?.path} |
| 1149 | 1187 | onOpenFiles={() => navigation.navigate("PullRequestFiles", route.params)} |
| 1188 | onHeightChange={setToolbarHeight} | |
| 1150 | 1189 | /> |
| 1151 | 1190 | </View> |
| 1152 | 1191 | ); |
apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx+91-35| ... | ... | @@ -1,9 +1,12 @@ |
| 1 | 1 | import { useNavigation } from "@react-navigation/native"; |
| 2 | 2 | import type { ReactNode } from "react"; |
| 3 | import { ScrollView, View } from "react-native"; | |
| 3 | import { Platform, ScrollView, useWindowDimensions, View } from "react-native"; | |
| 4 | 4 | import { KeyboardAvoidingView } from "react-native-keyboard-controller"; |
| 5 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 6 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; | |
| 8 | import { MaterialIconButton } from "../../components/MaterialIconButton"; | |
| 9 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | |
| 7 | 10 | import { ControlPill } from "../../components/ControlPill"; |
| 8 | 11 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 9 | 12 | |
| ... | ... | @@ -17,44 +20,97 @@ export function PullRequestSheetContent(props: { |
| 17 | 20 | }) { |
| 18 | 21 | const navigation = useNavigation(); |
| 19 | 22 | const insets = useSafeAreaInsets(); |
| 20 | return ( | |
| 21 | <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1 bg-sheet"> | |
| 22 | <NativeStackScreenOptions options={{ gestureEnabled: !props.busy }} /> | |
| 23 | <View className="flex-row items-center gap-2 px-4 pt-5 pb-5"> | |
| 24 | <ControlPill | |
| 25 | icon="xmark" | |
| 26 | accessibilityLabel="Close" | |
| 27 | disabled={props.busy} | |
| 28 | onPress={() => navigation.goBack()} | |
| 23 | const { height: windowHeight } = useWindowDimensions(); | |
| 24 | const android = Platform.OS === "android"; | |
| 25 | const fitToContents = android && props.scrollable !== false; | |
| 26 | const footer = props.footer ? ( | |
| 27 | <View | |
| 28 | className={ | |
| 29 | android && props.scrollable !== false ? "bg-sheet-solid pt-3" : "bg-sheet px-4 pt-3" | |
| 30 | } | |
| 31 | style={{ paddingBottom: Math.max(insets.bottom, 16) }} | |
| 32 | > | |
| 33 | {android ? <View style={{ minHeight: 48 }}>{props.footer}</View> : props.footer} | |
| 34 | </View> | |
| 35 | ) : null; | |
| 36 | const content = ( | |
| 37 | <> | |
| 38 | {android ? ( | |
| 39 | <AndroidSheetHeader | |
| 40 | title={props.title} | |
| 41 | subtitle={props.subtitle} | |
| 42 | hideBottomBorder | |
| 43 | leading={ | |
| 44 | <MaterialIconButton | |
| 45 | icon="arrow.left" | |
| 46 | accessibilityLabel="Close" | |
| 47 | disabled={props.busy} | |
| 48 | tintColorClassName="accent-header-foreground" | |
| 49 | onPress={() => navigation.goBack()} | |
| 50 | /> | |
| 51 | } | |
| 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 | <ScrollView | |
| 44 | className="flex-1" | |
| 45 | keyboardShouldPersistTaps="handled" | |
| 46 | contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 20 }} | |
| 47 | > | |
| 48 | <View className="gap-4" pointerEvents={props.busy ? "none" : "auto"}> | |
| 49 | {props.children} | |
| 54 | <View className="flex-row items-center gap-2 px-4 pt-5 pb-5"> | |
| 55 | <ControlPill | |
| 56 | icon="xmark" | |
| 57 | accessibilityLabel="Close" | |
| 58 | disabled={props.busy} | |
| 59 | onPress={() => navigation.goBack()} | |
| 60 | /> | |
| 61 | <View className="min-w-0 flex-1 items-center gap-1"> | |
| 62 | <Text className="text-center text-base font-t3-bold">{props.title}</Text> | |
| 63 | <Text numberOfLines={1} className="text-center text-xs text-foreground-muted"> | |
| 64 | {props.subtitle} | |
| 65 | </Text> | |
| 50 | 66 | </View> |
| 51 | </ScrollView> | |
| 52 | )} | |
| 53 | {props.footer ? ( | |
| 54 | <View className="bg-sheet px-4 pt-3" style={{ paddingBottom: Math.max(insets.bottom, 16) }}> | |
| 55 | {props.footer} | |
| 67 | <View className="w-11" /> | |
| 56 | 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 | 114 | </KeyboardAvoidingView> |
| 59 | 115 | ); |
| 60 | 116 | } |
apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx+9-8| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 2 | 2 | import { useState } from "react"; |
| 3 | import { View } from "react-native"; | |
| 3 | import { Platform, View } from "react-native"; | |
| 4 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | |
| 4 | 5 | import { AppText as Text } from "../../components/AppText"; |
| 5 | 6 | import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; |
| 6 | 7 | import { pullRequestStatus } from "./pullRequestStatus"; |
| ... | ... | @@ -12,6 +13,7 @@ export function PullRequestStackCard(props: { |
| 12 | 13 | readonly state: PullRequestStackState; |
| 13 | 14 | }) { |
| 14 | 15 | const navigation = useNavigation(); |
| 16 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | |
| 15 | 17 | const [expanded, setExpanded] = useState(false); |
| 16 | 18 | const stack = props.state.stack; |
| 17 | 19 | if (!stack) { |
| ... | ... | @@ -62,13 +64,12 @@ export function PullRequestStackCard(props: { |
| 62 | 64 | onPress={ |
| 63 | 65 | selected |
| 64 | 66 | ? undefined |
| 65 | : () => | |
| 66 | navigation.dispatch( | |
| 67 | StackActions.push("PullRequest", { | |
| 68 | ...props.params, | |
| 69 | number: layer.number, | |
| 70 | }), | |
| 71 | ) | |
| 67 | : () => { | |
| 68 | const params = { ...props.params, number: layer.number }; | |
| 69 | if (Platform.OS === "android") | |
| 70 | selectAndroidWorkspace({ name: "PullRequest", params }); | |
| 71 | else navigation.dispatch(StackActions.push("PullRequest", params)); | |
| 72 | } | |
| 72 | 73 | } |
| 73 | 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 | 36 | } from "./pullRequestList"; |
| 37 | 37 | import { useDebouncedValue } from "../../state/queries"; |
| 38 | 38 | import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; |
| 39 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | |
| 39 | 40 | |
| 40 | 41 | function PullRequestScrollView(props: ScrollViewProps) { |
| 41 | 42 | const insets = useSafeAreaInsets(); |
| ... | ... | @@ -77,19 +78,22 @@ export function PullRequestsScreen() { |
| 77 | 78 | const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId) |
| 78 | 79 | ? selectedEnvironmentId |
| 79 | 80 | : null; |
| 81 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | |
| 80 | 82 | const selectPullRequest = useCallback( |
| 81 | 83 | (item: EnvironmentPullRequestListEntry) => { |
| 82 | 84 | Keyboard.dismiss(); |
| 83 | navigation.navigate("PullRequest", { | |
| 85 | const params = { | |
| 84 | 86 | environmentId: String(item.environmentId), |
| 85 | 87 | projectId: String(item.projectId), |
| 86 | 88 | repository: item.repository, |
| 87 | 89 | number: item.number, |
| 88 | 90 | host: item.host, |
| 89 | 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 | 98 | const controlsAtom = pullRequestListControlsAtom(environmentId ?? ""); |
| 95 | 99 | const controls = useAtomValue(controlsAtom); |
apps/mobile/src/features/pull-requests/usePullRequestListActions.ts+10-4| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | } from "./pullRequestActions"; |
| 17 | 17 | import { scopedPullRequestListKey, type EnvironmentPullRequestListEntry } from "./pullRequestList"; |
| 18 | 18 | import { pullRequestTarget } from "./usePullRequestDiff"; |
| 19 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | |
| 19 | 20 | |
| 20 | 21 | export function usePullRequestListActions( |
| 21 | 22 | environmentIds: readonly EnvironmentId[], |
| ... | ... | @@ -26,6 +27,7 @@ export function usePullRequestListActions( |
| 26 | 27 | ) { |
| 27 | 28 | const registry = use(RegistryContext); |
| 28 | 29 | const navigation = useNavigation(); |
| 30 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | |
| 29 | 31 | const permissionKey = JSON.stringify(environmentIds); |
| 30 | 32 | const permissionAtom = useMemo(() => { |
| 31 | 33 | const ids: readonly EnvironmentId[] = JSON.parse(permissionKey); |
| ... | ... | @@ -74,7 +76,9 @@ export function usePullRequestListActions( |
| 74 | 76 | !pr.viewerPermissions.requestReviewers |
| 75 | 77 | ) |
| 76 | 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 | 82 | return; |
| 79 | 83 | } |
| 80 | 84 | if ( |
| ... | ... | @@ -102,12 +106,14 @@ export function usePullRequestListActions( |
| 102 | 106 | setBusyKey(null); |
| 103 | 107 | } |
| 104 | 108 | }, |
| 105 | [registry, navigation, params, run, onChanged], | |
| 109 | [registry, navigation, params, run, onChanged, selectAndroidWorkspace], | |
| 106 | 110 | ); |
| 107 | 111 | const select = useCallback( |
| 108 | 112 | (entry: EnvironmentPullRequestListEntry, action: string) => { |
| 109 | 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 | 117 | return; |
| 112 | 118 | } |
| 113 | 119 | if (action === "request-review") { |
| ... | ... | @@ -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 | 154 | const actions = useCallback( |
| 149 | 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 | 11 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 12 | 12 | import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; |
| 13 | 13 | import { useRef, useState } from "react"; |
| 14 | import { Alert } from "react-native"; | |
| 14 | import { Alert, Platform } from "react-native"; | |
| 15 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | |
| 15 | 16 | import { pullRequestComposerContext } from "../../lib/composerContext"; |
| 16 | 17 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 17 | 18 | import { uuidv4 } from "../../lib/uuid"; |
| ... | ... | @@ -70,10 +71,14 @@ export function usePullRequestThreads( |
| 70 | 71 | const canAsk = !connected || canOperate; |
| 71 | 72 | const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope); |
| 72 | 73 | const navigation = useNavigation(); |
| 74 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | |
| 73 | 75 | const pending = useRef(false); |
| 74 | 76 | const previousPrompts = useRef(new Map<string, string>()); |
| 75 | const openThread = (thread: LinkedThread) => | |
| 76 | navigation.navigate("Thread", { environmentId: params.environmentId, threadId: thread.id }); | |
| 77 | const openThread = (thread: LinkedThread) => { | |
| 78 | const destination = { environmentId: params.environmentId, threadId: thread.id }; | |
| 79 | if (Platform.OS === "android") selectAndroidWorkspace({ name: "Thread", params: destination }); | |
| 80 | else navigation.navigate("Thread", destination); | |
| 81 | }; | |
| 77 | 82 | const [busy, setBusy] = useState(false); |
| 78 | 83 | const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => { |
| 79 | 84 | if (!pr || !canAsk || pending.current) return; |
apps/mobile/src/features/threads/ThreadHeader.tsx+3-12| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import { StackActions, useNavigation } from "@react-navigation/native"; | |
| 2 | 1 | import { useMemo } from "react"; |
| 3 | 2 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 4 | 3 | import { ScreenHeaderButton } from "../../components/ScreenHeaderButton"; |
| ... | ... | @@ -6,6 +5,7 @@ import type { ScreenHeaderAction } from "../../components/ScreenHeader.types"; |
| 6 | 5 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 7 | 6 | import type { ThreadInspectorMode } from "./thread-inspector-content-stack"; |
| 8 | 7 | import { useThreadHeaderOptions } from "./useThreadHeaderOptions"; |
| 8 | import { useWorkspaceListNavigation } from "../workspace/useAndroidWorkspaceNavigation"; | |
| 9 | 9 | |
| 10 | 10 | export function ThreadHeader( |
| 11 | 11 | props: Parameters<typeof useThreadHeaderOptions>[0] & { |
| ... | ... | @@ -18,7 +18,7 @@ export function ThreadHeader( |
| 18 | 18 | readonly onOpenFilesInspector: () => void; |
| 19 | 19 | }, |
| 20 | 20 | ) { |
| 21 | const navigation = useNavigation(); | |
| 21 | const returnToList = useWorkspaceListNavigation("threads"); | |
| 22 | 22 | const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 23 | 23 | const { onOpenTerminal, onMergeBack } = props.gitControls; |
| 24 | 24 | const native = useThreadHeaderOptions(props); |
| ... | ... | @@ -96,16 +96,7 @@ export function ThreadHeader( |
| 96 | 96 | /> |
| 97 | 97 | ) : null |
| 98 | 98 | } |
| 99 | onBack={ | |
| 100 | layout.usesSplitView | |
| 101 | ? undefined | |
| 102 | : () => { | |
| 103 | // A deep link or cold start has no previous route; Home is the way out. | |
| 104 | // Read the history at press time: it changes without re-rendering this screen. | |
| 105 | if (navigation.canGoBack()) navigation.goBack(); | |
| 106 | else navigation.dispatch(StackActions.replace("Home")); | |
| 107 | } | |
| 108 | } | |
| 99 | onBack={layout.usesSplitView ? undefined : returnToList} | |
| 109 | 100 | actions={androidHeaderActions} |
| 110 | 101 | hideBottomBorder |
| 111 | 102 | /> |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+7-9| ... | ... | @@ -87,6 +87,10 @@ import { |
| 87 | 87 | } from "./thread-inspector-content-stack"; |
| 88 | 88 | import { threadRouteIsHydrating } from "./thread-route-hydration"; |
| 89 | 89 | import { ThreadHeader } from "./ThreadHeader"; |
| 90 | import { | |
| 91 | useAndroidWorkspaceBack, | |
| 92 | useWorkspaceListNavigation, | |
| 93 | } from "../workspace/useAndroidWorkspaceNavigation"; | |
| 90 | 94 | |
| 91 | 95 | interface ThreadInspectorSelection { |
| 92 | 96 | readonly routeThreadIdentity: string | null; |
| ... | ... | @@ -114,7 +118,7 @@ function ThreadPane(props: { readonly children: ReactNode }) { |
| 114 | 118 | } |
| 115 | 119 | |
| 116 | 120 | function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) { |
| 117 | const navigation = useNavigation(); | |
| 121 | const returnToList = useWorkspaceListNavigation("threads"); | |
| 118 | 122 | const { layout } = useAdaptiveWorkspaceLayout(); |
| 119 | 123 | const { themeVariables } = useAppearancePreferences(); |
| 120 | 124 | if (Platform.OS !== "android") return props.children; |
| ... | ... | @@ -124,14 +128,7 @@ function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) { |
| 124 | 128 | <ScreenHeader |
| 125 | 129 | title="Thread" |
| 126 | 130 | options={{ contentStyle: { backgroundColor: themeVariables["--color-header"] } }} |
| 127 | onBack={ | |
| 128 | layout.usesSplitView | |
| 129 | ? undefined | |
| 130 | : () => { | |
| 131 | if (navigation.canGoBack()) navigation.goBack(); | |
| 132 | else navigation.dispatch(StackActions.replace("Home")); | |
| 133 | } | |
| 134 | } | |
| 131 | onBack={layout.usesSplitView ? undefined : returnToList} | |
| 135 | 132 | hideBottomBorder |
| 136 | 133 | /> |
| 137 | 134 | <ThreadPane>{props.children}</ThreadPane> |
| ... | ... | @@ -190,6 +187,7 @@ function ThreadUnavailableScreen(props: { |
| 190 | 187 | } |
| 191 | 188 | |
| 192 | 189 | export function ThreadRouteScreen(props: ThreadRouteScreenProps) { |
| 190 | useAndroidWorkspaceBack("threads"); | |
| 193 | 191 | const connectionsReady = useConnectionsReady(); |
| 194 | 192 | const { connectionState } = useRemoteConnectionStatus(); |
| 195 | 193 | const { selectedThread } = useThreadSelection(); |
apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx created+98| ... | ... | @@ -0,0 +1,98 @@ |
| 1 | import { requireNativeView } from "expo"; | |
| 2 | import { CommonActions, useNavigation } from "@react-navigation/native"; | |
| 3 | import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation"; | |
| 4 | import { | |
| 5 | Keyboard, | |
| 6 | processColor, | |
| 7 | View, | |
| 8 | type NativeSyntheticEvent, | |
| 9 | type ViewProps, | |
| 10 | } from "react-native"; | |
| 11 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 12 | import { useKeyboardState } from "react-native-keyboard-controller"; | |
| 13 | ||
| 14 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | |
| 15 | import { flattenThemeColor, themeColorWithAlpha } from "../../lib/mobileTheme"; | |
| 16 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | |
| 17 | import { useWorkspaceTabs } from "./WorkspaceTabsContext"; | |
| 18 | import type { WorkspaceTab } from "./workspace-tabs-state"; | |
| 19 | ||
| 20 | export const ANDROID_WORKSPACE_RAIL_WIDTH = 80; | |
| 21 | ||
| 22 | interface 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 | ||
| 34 | const NativeNavigation = requireNativeView<NativeNavigationProps>("T3WorkspaceNavigation"); | |
| 35 | ||
| 36 | function nativeColor(value: string) { | |
| 37 | const color = processColor(value); | |
| 38 | return typeof color === "number" ? color : 0; | |
| 39 | } | |
| 40 | ||
| 41 | export 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. */ | |
| 2 | export function AndroidWorkspaceNavigation(_props: { | |
| 3 | readonly rail?: boolean; | |
| 4 | readonly onSelect?: () => void; | |
| 5 | }) { | |
| 6 | return null; | |
| 7 | } | |
| 8 | ||
| 9 | export const ANDROID_WORKSPACE_RAIL_WIDTH = 80; |
apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | import { useMemo, type ReactNode } from "react"; | |
| 2 | import { View } from "react-native"; | |
| 3 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | |
| 4 | import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; | |
| 5 | import { MaterialPullRequestToolbar } from "../pull-requests/MaterialPullRequestToolbar.android"; | |
| 6 | import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; | |
| 7 | ||
| 8 | export 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 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 2 | ||
| 3 | export 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 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 2 | import { Platform } from "react-native"; | |
| 3 | import { WorkspaceTabsProvider } from "./WorkspaceTabsContext"; | |
| 4 | ||
| 5 | /** Android's persistent sidebar and compact root share one tab controller. */ | |
| 6 | export 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 @@ |
| 1 | import { useMemo } from "react"; | |
| 2 | import { View } from "react-native"; | |
| 3 | import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 4 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 5 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 6 | import { EmptyState } from "../../components/EmptyState"; | |
| 7 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | |
| 8 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | |
| 9 | import { HomeRouteScreen } from "../home/HomeRouteScreen"; | |
| 10 | import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; | |
| 11 | import { MaterialPullRequestToolbar } from "../pull-requests/MaterialPullRequestToolbar.android"; | |
| 12 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | |
| 13 | import { AndroidWorkspaceNavigation } from "./AndroidWorkspaceNavigation"; | |
| 14 | import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; | |
| 15 | ||
| 16 | export 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 @@ |
| 1 | import { | |
| 2 | CommonActions, | |
| 3 | StackActions, | |
| 4 | useFocusEffect, | |
| 5 | useNavigation, | |
| 6 | } from "@react-navigation/native"; | |
| 7 | import { useCallback } from "react"; | |
| 8 | import { BackHandler, Keyboard, Platform } from "react-native"; | |
| 9 | import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation"; | |
| 10 | import { useWorkspaceTabs } from "./WorkspaceTabsContext"; | |
| 11 | import type { WorkspaceTab } from "./workspace-tabs-state"; | |
| 12 | ||
| 13 | export 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. */ | |
| 26 | export 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 @@ |
| 1 | import { CommonActions, useNavigation } from "@react-navigation/native"; | |
| 2 | import { useCallback } from "react"; | |
| 3 | import { Keyboard } from "react-native"; | |
| 4 | import { | |
| 5 | androidWorkspaceNavigationState, | |
| 6 | type AndroidWorkspaceDestination, | |
| 7 | } from "../../lib/android-workspace-navigation"; | |
| 8 | import { useWorkspaceTabs } from "./WorkspaceTabsContext"; | |
| 9 | ||
| 10 | export 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 @@ |
| 1 | import * as NodeModule from "node:module"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | import { | |
| 4 | androidWorkspaceNavigationState, | |
| 5 | type AndroidWorkspaceDestination, | |
| 6 | } from "./android-workspace-navigation"; | |
| 7 | ||
| 8 | const require = NodeModule.createRequire(import.meta.url); | |
| 9 | const nativePackage = require.resolve("@react-navigation/native/package.json"); | |
| 10 | const requireFromNative = NodeModule.createRequire(nativePackage); | |
| 11 | const corePackage = requireFromNative.resolve("@react-navigation/core/package.json"); | |
| 12 | const requireFromCore = NodeModule.createRequire(corePackage); | |
| 13 | const { 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 | }; | |
| 20 | const router = StackRouter({}); | |
| 21 | const 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 | }; | |
| 36 | const thread = { | |
| 37 | name: "Thread", | |
| 38 | params: { environmentId: "environment-a", threadId: "thread-a" }, | |
| 39 | } as const; | |
| 40 | const 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 | ||
| 51 | function 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 | ||
| 60 | function 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 | ||
| 71 | function 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 | ||
| 78 | function names(state: ReturnType<typeof router.getInitialState>) { | |
| 79 | return state.routes.map((route) => route.name); | |
| 80 | } | |
| 81 | ||
| 82 | describe("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 @@ |
| 1 | import type { NavigationState } from "@react-navigation/native"; | |
| 2 | ||
| 3 | export 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. */ | |
| 8 | export 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 | 49 | }; |
| 50 | 50 | |
| 51 | 51 | function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { |
| 52 | if (typeof icon !== "string") { | |
| 53 | return undefined; | |
| 52 | if (typeof icon === "string") return { type: "sfSymbol", name: icon as never }; | |
| 53 | if ( | |
| 54 | typeof icon === "object" && | |
| 55 | icon !== null && | |
| 56 | "type" in icon && | |
| 57 | icon.type === "image" && | |
| 58 | "source" in icon | |
| 59 | ) { | |
| 60 | return icon as NativeStackHeaderIcon; | |
| 54 | 61 | } |
| 55 | return { type: "sfSymbol", name: icon as never }; | |
| 62 | return undefined; | |
| 56 | 63 | } |
| 57 | 64 | |
| 58 | 65 | type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode }; |
| ... | ... | @@ -275,7 +282,7 @@ function NativeHeaderToolbarButton(_props: { |
| 275 | 282 | readonly accessibilityLabel?: string; |
| 276 | 283 | readonly axisBehavior?: NativeStackHeaderAxisBehavior; |
| 277 | 284 | readonly disabled?: boolean; |
| 278 | readonly icon?: string; | |
| 285 | readonly icon?: string | Extract<NativeStackHeaderIcon, { type: "image" }>; | |
| 279 | 286 | readonly label?: string; |
| 280 | 287 | readonly onPress?: () => void; |
| 281 | 288 | readonly separateBackground?: boolean; |
apps/mobile/src/native/useOpenPullRequest.ts+10-1| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | import { useNavigation, useRoute, StackActions } from "@react-navigation/native"; |
| 2 | 2 | import { nativePullRequestDestination } from "../lib/pullRequestNavigation"; |
| 3 | 3 | import { useCallback } from "react"; |
| 4 | import { Platform } from "react-native"; | |
| 5 | import { CommonActions } from "@react-navigation/native"; | |
| 6 | import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation"; | |
| 4 | 7 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 5 | 8 | import { useThreadShell } from "../state/entities"; |
| 6 | 9 | import { tryOpenExternalUrl } from "../lib/openExternalUrl"; |
| ... | ... | @@ -42,7 +45,13 @@ export function useOpenPullRequest(options?: { |
| 42 | 45 | : null, |
| 43 | 46 | ); |
| 44 | 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 | 55 | else navigation.navigate("PullRequest", params); |
| 47 | 56 | return true; |
| 48 | 57 | }, |