From c2f03a08d1aaee79879a3f5f1e7f5246b10b8d3e Mon Sep 17 00:00:00 2001 From: none23 Date: Fri, 16 Oct 2026 12:17:35 +0400 Subject: [PATCH 1/2] fix(mobile): move Android PR filters into the header --- .../pull-requests/PullRequestListFilters.tsx | 97 +++++++++++++------ .../PullRequestToolbar.android.tsx | 33 ++++--- .../pull-requests/PullRequestsScreen.tsx | 58 ++++++----- .../AndroidWorkspaceSidebar.android.tsx | 11 +-- .../workspace/WorkspaceRootScreen.android.tsx | 9 +- 5 files changed, 129 insertions(+), 79 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx index 2030409f2ef5c..9963e57311531 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx @@ -1,6 +1,8 @@ import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; import { useMemo } from "react"; -import { ScrollView } from "react-native"; +import { Platform, ScrollView } from "react-native"; +import { AndroidAnchoredMenu } from "../../components/ui/AndroidAnchoredMenu"; +import { AndroidHeaderIconButton } from "../../components/ui/AndroidScreenHeader"; import type { MenuProps } from "../../components/ui/Menu.types"; import { ListFilterMenu } from "../workspace/ListFilterMenu"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -221,6 +223,67 @@ export function PullRequestListFilters(props: { const authors = [ ...new Set([...(author && author !== "me" ? [author] : []), ...props.authors]), ].slice(0, 60); + const environmentActions = choices( + "environment", + Object.fromEntries([ + ["", "All environments"], + ...props.environments.map((environment) => [environment.id, environment.label]), + ]), + props.environmentId ?? "", + ); + const labelActions: MenuProps["actions"] = [ + { + id: id("labels", ""), + title: "All labels", + state: selectedLabels.length ? "off" : "on", + }, + ...labels.map((label) => ({ + id: id("label", label), + title: label, + state: selectedLabelSet.has(label) ? ("on" as const) : ("off" as const), + })), + ]; + const authorActions = choices( + "author", + Object.fromEntries([ + ["", "All authors"], + ["me", "Me"], + ...authors.map((login) => [login, login]), + ]), + author ?? "", + ); + if (Platform.OS === "android") { + const active = count > 0 || value.sort !== "updated" || props.environmentId !== null; + return ( + 1 + ? [{ id: "environments", title: "Environment", subactions: environmentActions }] + : []), + { id: "states", title: "State", subactions: choices("state", STATES, value.state) }, + { + id: "involvement", + title: "Involvement", + subactions: choices("involvement", INVOLVEMENT, value.involvement), + }, + { id: "labels", title: "Labels", subactions: labelActions }, + { id: "authors", title: "Author", subactions: authorActions }, + ...more, + ]} + onPressAction={({ nativeEvent }) => select(nativeEvent.event)} + > + {(open) => ( + + )} + + ); + } return ( [environment.id, environment.label]), - ]), - props.environmentId ?? "", - )} + actions={environmentActions} onSelect={select} /> ) : null} @@ -271,33 +327,14 @@ export function PullRequestListFilters(props: { label={selectedLabels.length ? `Labels ${selectedLabels.length}` : "Labels"} active={selectedLabels.length > 0} accessibilityLabel="Filter by label" - actions={[ - { - id: id("labels", ""), - title: "All labels", - state: selectedLabels.length ? "off" : "on", - }, - ...labels.map((label) => ({ - id: id("label", label), - title: label, - state: selectedLabelSet.has(label) ? ("on" as const) : ("off" as const), - })), - ]} + actions={labelActions} onSelect={select} /> [login, login]), - ]), - author ?? "", - )} + actions={authorActions} onSelect={select} /> diff --git a/apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx b/apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx index b685cd8cd4d8b..a0eb323804e41 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx @@ -1,5 +1,5 @@ import { useNavigation, useNavigationState } from "@react-navigation/native"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, type ReactNode } from "react"; import { BackHandler, Keyboard, View, type TextInputInstance } from "react-native"; import { AndroidHeaderIconButton, @@ -12,7 +12,10 @@ import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; /** PR search shares the thread list's native Android header placement. */ -export function PullRequestToolbar(props: { readonly onRequestVisibility?: () => void }) { +export function PullRequestToolbar(props: { + readonly filters: ReactNode; + readonly onRequestVisibility?: () => void; +}) { const navigation = useNavigation(); const workspace = useWorkspaceTabs()!; const { dispatch, getScrollOffset, recordScrollOffset, isTabActive } = workspace; @@ -103,17 +106,25 @@ export function PullRequestToolbar(props: { readonly onRequestVisibility?: () => icon: "magnifyingglass", onPress: openSearch, }, - { - accessibilityLabel: "Open settings", - icon: "gearshape", - onPress: () => - navigation.navigate("SettingsSheet", { - screen: "SettingsContent", - params: { screen: "Settings" }, - }), - }, ] } + trailing={ + <> + {props.filters} + {!searching ? ( + + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "Settings" }, + }) + } + /> + ) : null} + + } /> ); } diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index 35c48d29d496d..54da9bedebafc 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -18,12 +18,14 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; +import { ScreenContent } from "../../components/ui/ScreenContent"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import { useWorkspaceState } from "../../state/workspace"; import { useProjects } from "../../state/entities"; import { environmentServerConfigsAtom } from "../../state/server"; import { PullRequestListRow } from "./PullRequestListRow"; import { PullRequestListFilters } from "./PullRequestListFilters"; +import { PullRequestToolbar } from "./PullRequestToolbar.android"; import { usePullRequestList } from "./usePullRequestList"; import { usePullRequestListControls } from "./usePullRequestListControls"; import { usePullRequestListActions } from "./usePullRequestListActions"; @@ -58,6 +60,7 @@ export function PullRequestsScreen( props: { readonly sidebar?: boolean; readonly selectedPullRequestKey?: string | null; + readonly onRequestVisibility?: () => void; } = {}, ) { const navigation = useNavigation(); @@ -212,7 +215,28 @@ export function PullRequestsScreen( ? entries.findIndex((entry) => pullRequestKey(entry) === initialAnchor.key) : -1; - return ( + const filters = ( + ({ + id: String(item.environmentId), + label: item.environmentLabel, + }))} + environmentId={environmentId} + onEnvironmentChange={listControls.setEnvironmentId} + value={controls} + onChange={(next) => { + listControls.setControls(environmentId, next); + void listRef.current?.scrollToOffset({ + offset: Platform.OS === "ios" ? -insets.top : 0, + animated: false, + }); + }} + authors={knownFacets.environmentId === environmentId ? knownFacets.authors : facets.authors} + labels={knownFacets.environmentId === environmentId ? knownFacets.labels : facets.labels} + projects={projects} + /> + ); + const content = ( - ({ - id: String(item.environmentId), - label: item.environmentLabel, - }))} - environmentId={environmentId} - onEnvironmentChange={listControls.setEnvironmentId} - value={controls} - onChange={(next) => { - listControls.setControls(environmentId, next); - void listRef.current?.scrollToOffset({ - offset: Platform.OS === "ios" ? -insets.top : 0, - animated: false, - }); - }} - authors={ - knownFacets.environmentId === environmentId ? knownFacets.authors : facets.authors - } - labels={ - knownFacets.environmentId === environmentId ? knownFacets.labels : facets.labels - } - projects={projects} - /> + {Platform.OS !== "android" ? filters : null} {result.data?.errors.length ? ( {result.data.errors @@ -364,4 +366,12 @@ export function PullRequestsScreen( /> ); + return ( + <> + {Platform.OS === "android" ? ( + + ) : null} + {content} + + ); } diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx index 0db7c38fd43d8..2121044223ef6 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx @@ -1,8 +1,6 @@ import { useMemo, type ReactNode } from "react"; import { View } from "react-native"; -import { ScreenContent } from "../../components/ui/ScreenContent"; import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; -import { PullRequestToolbar } from "../pull-requests/PullRequestToolbar.android"; import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; export function AndroidWorkspaceSidebar(props: { @@ -17,10 +15,11 @@ export function AndroidWorkspaceSidebar(props: { ) : ( - - - - + ); diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx index 367ee3acbff89..264ac94b361be 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx @@ -4,11 +4,9 @@ import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area import { NativeStackScreenOptions } from "../../native/StackHeader"; import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; import { EmptyState } from "../../components/ui/EmptyState"; -import { ScreenContent } from "../../components/ui/ScreenContent"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { HomeRouteScreen } from "../home/HomeRouteScreen"; import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; -import { PullRequestToolbar } from "../pull-requests/PullRequestToolbar.android"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { AndroidWorkspaceNavigation } from "./AndroidWorkspaceNavigation"; import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; @@ -53,12 +51,7 @@ export function WorkspaceRootScreen() { {workspace.state.selectedTab === "threads" ? ( ) : ( - <> - - - - - + )} From 1b07b80510433c12d084db3a0605ace88302eb6f Mon Sep 17 00:00:00 2001 From: none23 Date: Fri, 16 Oct 2026 12:47:48 +0400 Subject: [PATCH 2/2] fix(mobile): align the PR header with chats --- .../src/features/home/ThreadListToolbar.tsx | 49 +++++++--------- .../PullRequestToolbar.android.tsx | 56 +++---------------- .../workspace/AndroidWorkspaceHeader.tsx | 54 ++++++++++++++++++ 3 files changed, 82 insertions(+), 77 deletions(-) create mode 100644 apps/mobile/src/features/workspace/AndroidWorkspaceHeader.tsx diff --git a/apps/mobile/src/features/home/ThreadListToolbar.tsx b/apps/mobile/src/features/home/ThreadListToolbar.tsx index 298ab2903df32..05525f0f7e96a 100644 --- a/apps/mobile/src/features/home/ThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/ThreadListToolbar.tsx @@ -12,16 +12,14 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { MenuAction } from "@react-native-menu/menu"; import { AndroidHeaderIconButton } from "../../components/ui/AndroidScreenHeader"; -import { CompactBrandTitle } from "../../components/CompactBrandTitle"; import { FloatingActionButton } from "../../components/ui/FloatingActionButton"; import { AndroidAnchoredMenu } from "../../components/ui/AndroidAnchoredMenu"; import { Menu } from "../../components/ui/Menu"; import { SearchField } from "../../components/ui/SearchField"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; -import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; +import { AndroidWorkspaceHeader } from "../workspace/AndroidWorkspaceHeader"; import { useWorkspaceState } from "../../state/workspace"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { useToolbarLayout } from "../../components/ui/useToolbarLayout"; /** One toolbar height for the compact list and expanded sidebar, including search. */ export function ThreadListToolbar(props: { @@ -44,7 +42,6 @@ export function ThreadListToolbar(props: { const listFocused = homeFocused && (workspace === null || workspace.state.selectedTab === "threads"); const { fabSize } = useAndroidControlSizing(); - const { height: toolbarHeight, ...headerPadding } = useToolbarLayout(); const { state } = useWorkspaceState(); const { onRequestVisibility, onSearchQueryChange } = props; const searchRef = useRef(null); @@ -98,32 +95,24 @@ export function ThreadListToolbar(props: { return ( <> - + ) : undefined } - style={headerPadding} - > - - {searching ? ( + trailing={ + !searching ? ( <> - - {searchField} - - ) : ( - <> - - } - /> - - )} - - + ) : null + } + /> {state.hasConnections ? ( subscription.remove(); }, [searching, closeSearch, listFocused, navigation, isTabActive]); return ( - dispatch({ type: "query", tab: "pullRequests", query })} /> - ) : ( - - - } - onPress={() => - navigation.navigate("SettingsSheet", { - screen: "SettingsContent", - params: { screen: "SettingsEnvironments" }, - }) - } - /> - - ) + ) : undefined } leading={ searching ? ( @@ -97,32 +75,16 @@ export function PullRequestToolbar(props: { /> ) : undefined } - actions={ - searching - ? [] - : [ - { - accessibilityLabel: "Search pull requests", - icon: "magnifyingglass", - onPress: openSearch, - }, - ] - } trailing={ <> - {props.filters} {!searching ? ( - navigation.navigate("SettingsSheet", { - screen: "SettingsContent", - params: { screen: "Settings" }, - }) - } + accessibilityLabel="Search pull requests" + icon="magnifyingglass" + onPress={openSearch} /> ) : null} + {props.filters} } /> diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceHeader.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceHeader.tsx new file mode 100644 index 0000000000000..1b74267ffaf62 --- /dev/null +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceHeader.tsx @@ -0,0 +1,54 @@ +import { useNavigation } from "@react-navigation/native"; +import type { ReactNode } from "react"; +import { View, type LayoutChangeEvent } from "react-native"; + +import { CompactBrandTitle } from "../../components/CompactBrandTitle"; +import { useToolbarLayout } from "../../components/ui/useToolbarLayout"; +import { WorkspaceConnectionTitle } from "../home/WorkspaceConnectionTitle"; + +/** A fixed row keeps the Android list headers aligned regardless of their actions or search. */ +export function AndroidWorkspaceHeader(props: { + readonly title: string; + readonly titleContent?: ReactNode; + readonly leading?: ReactNode; + readonly trailing?: ReactNode; + readonly sidebar?: boolean; + readonly onLayout?: (event: LayoutChangeEvent) => void; + readonly onOpenEnvironments?: () => void; +}) { + const navigation = useNavigation(); + const { height, ...headerPadding } = useToolbarLayout(); + return ( + + + {props.leading} + {props.titleContent ?? ( + + + } + onPress={ + props.onOpenEnvironments ?? + (() => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + })) + } + /> + + )} + {props.trailing} + + + ); +}