authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 12:55:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 12:55:16-07:00
log88b9226d229a7d51283122a8b8b8be8305822a9e
treeb64ef5a8e852502da0de7841c785e7c3ffcd8365
parentaba1c6352d77c33188dc03d2b76bdfe489c95542
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): compose review menus through the shared header (#12400)


3 files changed, 190 insertions(+), 222 deletions(-)

apps/mobile/src/features/review/ReviewSheet.tsx+110-222
......@@ -1,11 +1,10 @@
11import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import type { MenuAction } from "@react-native-menu/menu";
32import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
4import {
5 NativeHeaderToolbar,
6 NativeStackScreenOptions,
7 nativeHeaderScrollEdgeEffects,
8} from "../../native/StackHeader";
3import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
4import { ScreenHeader } from "../../components/ScreenHeader";
5import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types";
6import type { ReviewSectionItem } from "./reviewModel";
7import { useReviewHeaderPresentation } from "./useReviewHeaderPresentation";
98import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
109import {
1110 memo,
......@@ -33,8 +32,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
3332
3433import { AppText as Text } from "../../components/AppText";
3534import { SymbolView } from "../../components/AppSymbol";
36import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
37import { ControlPillMenu } from "../../components/ControlPill";
35import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
3836import { MaterialScreenContent } from "../../components/MaterialScreenContent";
3937import { cn } from "../../lib/cn";
4038import { environmentCatalog } from "../../connection/catalog";
......@@ -50,17 +48,7 @@ import {
5048 useAdaptiveWorkspacePaneRole,
5149 useRegisterWorkspaceInspector,
5250} from "../layout/AdaptiveWorkspaceLayout";
53import { useEnvironmentQuery } from "../../state/query";
54import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
55import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
5651import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
57import { useThreadSelection } from "../../state/use-thread-selection";
58import { vcsEnvironment } from "../../state/vcs";
59import {
60 AndroidWorkspaceSidebarButton,
61 WorkspaceSidebarToolbar,
62} from "../layout/workspace-sidebar-toolbar";
63import { ThreadGitMenu } from "../threads/ThreadGitControls";
6452import { useReviewCacheForThread } from "./reviewState";
6553import {
6654 isNativeReviewDiffDrawEvent,
......@@ -78,9 +66,96 @@ import { useReviewCommentSelectionController } from "./useReviewCommentSelection
7866import { resolveReviewAvailability } from "./reviewAvailability";
7967import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
8068import { buildReviewSectionMenu } from "./review-section-menu";
81import type { ReviewSectionItem } from "./reviewModel";
8269import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal";
8370
71function ReviewHeader(
72 props: Parameters<typeof useReviewHeaderPresentation>[0] & {
73 readonly iconColor: string;
74 readonly sectionMenu: ReturnType<typeof buildReviewSectionMenu>;
75 readonly showSectionToolbar: boolean;
76 readonly showChangedFilesToggle: boolean;
77 readonly onSelectSection: (sectionId: string) => void;
78 readonly onReturnToThread: () => void;
79 },
80) {
81 const { panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
82 const presentation = useReviewHeaderPresentation(props);
83 const sectionAction = (
84 section: ReviewSectionItem | null,
85 title: string,
86 ): ScreenHeaderMenuItem => ({
87 id: section ? `section:${section.id}` : `unavailable:${title}`,
88 title,
89 disabled: section === null,
90 selected: section !== null && section.id === props.selectedSection?.id,
91 onPress: () => {
92 if (section) props.onSelectSection(section.id);
93 },
94 });
95 return (
96 <ScreenHeader
97 title={presentation.title}
98 subtitle={presentation.subtitle}
99 onBack={props.onReturnToThread}
100 hideBottomBorder
101 options={{ headerTintColor: props.iconColor, headerTitle: props.title }}
102 backInSplitView={{ accessibilityLabel: "Back to chat", icon: "chevron.left" }}
103 actions={
104 props.showChangedFilesToggle
105 ? [
106 {
107 accessibilityLabel: panes.auxiliaryPaneVisible
108 ? "Hide changed files"
109 : "Show changed files",
110 icon: "sidebar.right",
111 selected: panes.auxiliaryPaneVisible,
112 onPress: toggleAuxiliaryPane,
113 },
114 ]
115 : undefined
116 }
117 menus={[
118 ...(presentation.gitMenu ? [presentation.gitMenu] : []),
119 ...(props.showSectionToolbar
120 ? [
121 {
122 title: "Select diff",
123 icon: presentation.menuIcon,
124 items: [
125 {
126 id: "sections",
127 inline: true,
128 items: [
129 sectionAction(props.sectionMenu.workingTree, "Working tree"),
130 sectionAction(props.sectionMenu.branchChanges, "Branch changes"),
131 sectionAction(props.sectionMenu.latestTurn, "Latest turn"),
132 ],
133 },
134 ...(props.sectionMenu.turns.length > 0
135 ? [
136 {
137 id: "turns",
138 title: "Turn",
139 items: props.sectionMenu.turns.map((section) => ({
140 id: `section:${section.id}`,
141 title: section.title,
142 subtitle: section.subtitle ?? undefined,
143 selected: section.id === props.selectedSection?.id,
144 onPress: () => props.onSelectSection(section.id),
145 })),
146 },
147 ]
148 : []),
149 ...(presentation.refreshAction ? [presentation.refreshAction] : []),
150 ],
151 },
152 ]
153 : []),
154 ]}
155 />
156 );
157}
158
84159const REVIEW_HEADER_SPACING = 0;
85160const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
86161
......@@ -370,10 +445,9 @@ type ReviewSheetProps = StaticScreenProps<{
370445}>;
371446
372447export function ReviewSheet(props: ReviewSheetProps) {
373 const isAndroid = Platform.OS === "android";
374448 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
375449 useAdaptiveWorkspacePaneRole("inspector");
376 const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
450 const { panes, showAuxiliaryPane } = useAdaptiveWorkspaceLayout();
377451 const navigation = useNavigation();
378452 const insets = useSafeAreaInsets();
379453 const { themeAppearance: selectedTheme } = useAppearancePreferences();
......@@ -384,23 +458,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
384458 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
385459 const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
386460 const reviewCache = useReviewCacheForThread({ environmentId, threadId });
387 /* ─── Git actions for the toolbar menu (commit/push without leaving review) ── */
388 const { selectedThread } = useThreadSelection();
389461 const { selectedThreadCwd } = useSelectedThreadWorktree();
390 const gitState = useSelectedThreadGitState();
391 const gitActions = useSelectedThreadGitActions();
392 const gitStatusQuery = useEnvironmentQuery(
393 selectedThread !== null && selectedThreadCwd !== null
394 ? vcsEnvironment.status({
395 environmentId: selectedThread.environmentId,
396 input: { cwd: selectedThreadCwd },
397 })
398 : null,
399 );
400 // The selection-based git hooks only apply when this review belongs to the
401 // selected thread (it always does when reached from the thread's toolbar).
402 const gitMenuAvailable =
403 selectedThread !== null && String(selectedThread.id) === String(threadId);
404462 // With a solid (non-overlay) header the content lays out below the header
405463 // natively, so no manual top inset is needed. (Android renders its own
406464 // in-flow AndroidScreenHeader, so it needs no inset either.)
......@@ -581,52 +639,6 @@ export function ReviewSheet(props: ReviewSheetProps) {
581639 hasCachedSelectedDiff,
582640 hasAnyCachedDiff,
583641 });
584 const androidSectionMenuActions = useMemo<MenuAction[]>(() => {
585 const sectionAction = (section: ReviewSectionItem | null, title: string): MenuAction => ({
586 id: section ? `section:${section.id}` : `unavailable:${title}`,
587 title: section?.id === selectedSection?.id ? `${title} (selected)` : title,
588 attributes: section ? undefined : { disabled: true },
589 });
590 const actions: MenuAction[] = [
591 sectionAction(sectionMenu.workingTree, "Working tree"),
592 sectionAction(sectionMenu.branchChanges, "Branch changes"),
593 sectionAction(sectionMenu.latestTurn, "Latest turn"),
594 ];
595
596 if (sectionMenu.turns.length > 0) {
597 actions.push({
598 id: "turns",
599 title: "Turn",
600 subactions: sectionMenu.turns.map((section) => ({
601 id: `section:${section.id}`,
602 title: section.id === selectedSection?.id ? `${section.title} (selected)` : section.title,
603 subtitle: section.subtitle ?? undefined,
604 })),
605 });
606 }
607
608 // The Android native diff surface has no pull-to-refresh, so refresh
609 // stays a menu action there (iOS refreshes via pull-to-refresh instead).
610 actions.push({
611 id: "refresh",
612 title: "Refresh current diff",
613 attributes: {
614 disabled: !selectedSection || selectedSection.isLoading,
615 },
616 });
617 return actions;
618 }, [sectionMenu, selectedSection]);
619 const handleAndroidSectionMenuAction = useCallback(
620 (event: { nativeEvent: { event: string } }) => {
621 const id = event.nativeEvent.event;
622 if (id === "refresh") {
623 void handlePullToRefresh();
624 } else if (id.startsWith("section:")) {
625 selectSection(id.slice("section:".length));
626 }
627 },
628 [handlePullToRefresh, selectSection],
629 );
630642 const handleRetryEnvironment = useCallback(() => {
631643 void retryEnvironment(environmentId);
632644 }, [environmentId, retryEnvironment]);
......@@ -703,147 +715,23 @@ export function ReviewSheet(props: ReviewSheetProps) {
703715
704716 return (
705717 <>
706 <NativeStackScreenOptions
707 options={
708 isAndroid
709 ? // Android draws its own in-flow header (AndroidScreenHeader below).
710 { headerShown: false }
711 : {
712 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the native
713 // diff scrolls internally, nothing for glass to sample). Only dynamic values
714 // here.
715 headerTintColor: headerIcon,
716 headerTitle: headerTitleText,
717 title: headerTitleText,
718 unstable_headerSubtitle:
719 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
720 }
721 }
718 <ReviewHeader
719 environmentId={environmentId}
720 threadId={threadId}
721 title={headerTitleText}
722 subtitle={headerSubtitle}
723 androidSubtitle={androidHeaderSubtitle}
724 iconColor={headerIcon}
725 selectedThreadCwd={selectedThreadCwd}
726 sectionMenu={sectionMenu}
727 selectedSection={selectedSection}
728 showSectionToolbar={showSectionToolbar}
729 showChangedFilesToggle={showChangedFilesToggle}
730 onRefresh={handlePullToRefresh}
731 onSelectSection={selectSection}
732 onReturnToThread={handleReturnToThread}
722733 />
723734
724 {isAndroid ? (
725 <AndroidScreenHeader
726 title="Review changes"
727 leading={<AndroidWorkspaceSidebarButton />}
728 hideBottomBorder
729 subtitle={androidHeaderSubtitle || "Select a diff"}
730 onBack={handleReturnToThread}
731 trailing={
732 <>
733 {showChangedFilesToggle ? (
734 <AndroidHeaderIconButton
735 accessibilityLabel={
736 panes.auxiliaryPaneVisible ? "Hide changed files" : "Show changed files"
737 }
738 icon="sidebar.right"
739 selected={panes.auxiliaryPaneVisible}
740 onPress={toggleAuxiliaryPane}
741 />
742 ) : null}
743 {showSectionToolbar ? (
744 <ControlPillMenu
745 actions={androidSectionMenuActions}
746 isAnchoredToRight
747 onPressAction={handleAndroidSectionMenuAction}
748 >
749 <AndroidHeaderIconButton
750 accessibilityLabel="Select review diff"
751 icon="ellipsis.circle"
752 />
753 </ControlPillMenu>
754 ) : null}
755 </>
756 }
757 />
758 ) : null}
759
760 <WorkspaceSidebarToolbar>
761 <NativeHeaderToolbar.Button
762 accessibilityLabel="Back to chat"
763 icon="chevron.left"
764 onPress={handleReturnToThread}
765 />
766 </WorkspaceSidebarToolbar>
767
768 {!isAndroid && (showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable) ? (
769 <NativeHeaderToolbar placement="right">
770 {showChangedFilesToggle ? (
771 <NativeHeaderToolbar.Button
772 accessibilityLabel={
773 panes.auxiliaryPaneVisible ? "Hide changed files" : "Show changed files"
774 }
775 icon="sidebar.right"
776 onPress={toggleAuxiliaryPane}
777 separateBackground
778 />
779 ) : null}
780 {gitMenuAvailable && selectedThread !== null ? (
781 <ThreadGitMenu
782 environmentId={environmentId}
783 threadId={threadId}
784 currentBranch={selectedThread.branch ?? null}
785 gitStatus={gitStatusQuery.data}
786 gitOperationLabel={gitState.gitOperationLabel}
787 onPull={gitActions.onPullSelectedThreadBranch}
788 onRunAction={gitActions.onRunSelectedThreadGitAction}
789 />
790 ) : null}
791 {showSectionToolbar ? (
792 <NativeHeaderToolbar.Menu icon="ellipsis" title="Select diff" separateBackground>
793 <NativeHeaderToolbar.Menu inline>
794 <NativeHeaderToolbar.MenuAction
795 disabled={sectionMenu.workingTree === null}
796 isOn={selectedSection?.id === sectionMenu.workingTree?.id}
797 onPress={() => {
798 if (sectionMenu.workingTree) {
799 selectSection(sectionMenu.workingTree.id);
800 }
801 }}
802 >
803 <NativeHeaderToolbar.Label>Working tree</NativeHeaderToolbar.Label>
804 </NativeHeaderToolbar.MenuAction>
805 <NativeHeaderToolbar.MenuAction
806 disabled={sectionMenu.branchChanges === null}
807 isOn={selectedSection?.id === sectionMenu.branchChanges?.id}
808 onPress={() => {
809 if (sectionMenu.branchChanges) {
810 selectSection(sectionMenu.branchChanges.id);
811 }
812 }}
813 >
814 <NativeHeaderToolbar.Label>Branch changes</NativeHeaderToolbar.Label>
815 </NativeHeaderToolbar.MenuAction>
816 <NativeHeaderToolbar.MenuAction
817 disabled={sectionMenu.latestTurn === null}
818 isOn={selectedSection?.id === sectionMenu.latestTurn?.id}
819 onPress={() => {
820 if (sectionMenu.latestTurn) {
821 selectSection(sectionMenu.latestTurn.id);
822 }
823 }}
824 >
825 <NativeHeaderToolbar.Label>Latest turn</NativeHeaderToolbar.Label>
826 </NativeHeaderToolbar.MenuAction>
827 {sectionMenu.turns.length > 0 ? (
828 <NativeHeaderToolbar.Menu title="Turn">
829 {sectionMenu.turns.map((section) => (
830 <NativeHeaderToolbar.MenuAction
831 key={section.id}
832 isOn={section.id === selectedSection?.id}
833 onPress={() => selectSection(section.id)}
834 subtitle={section.subtitle ?? undefined}
835 >
836 <NativeHeaderToolbar.Label>{section.title}</NativeHeaderToolbar.Label>
837 </NativeHeaderToolbar.MenuAction>
838 ))}
839 </NativeHeaderToolbar.Menu>
840 ) : null}
841 </NativeHeaderToolbar.Menu>
842 </NativeHeaderToolbar.Menu>
843 ) : null}
844 </NativeHeaderToolbar>
845 ) : null}
846
847735 <MaterialScreenContent>
848736 <View className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"}>
849737 {showConnectionNotice ? (
apps/mobile/src/features/review/useReviewHeaderPresentation.android.ts created+20
......@@ -0,0 +1,20 @@
1import type { useReviewHeaderPresentation as useIosReviewHeaderPresentation } from "./useReviewHeaderPresentation";
2
3export function useReviewHeaderPresentation(
4 props: Parameters<typeof useIosReviewHeaderPresentation>[0],
5): ReturnType<typeof useIosReviewHeaderPresentation> {
6 return {
7 title: "Review changes",
8 subtitle: props.androidSubtitle || "Select a diff",
9 gitMenu: null,
10 menuIcon: "ellipsis.circle",
11 refreshAction: {
12 id: "refresh",
13 title: "Refresh current diff",
14 disabled: !props.selectedSection || props.selectedSection.isLoading,
15 onPress: () => {
16 void props.onRefresh();
17 },
18 },
19 };
20}
apps/mobile/src/features/review/useReviewHeaderPresentation.tsx created+60
......@@ -0,0 +1,60 @@
1import { useEnvironmentQuery } from "../../state/query";
2import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
3import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
4import { useThreadSelection } from "../../state/use-thread-selection";
5import { vcsEnvironment } from "../../state/vcs";
6import { useThreadGitMenuDefinition } from "../threads/ThreadGitControls";
7import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import type { ReviewSectionItem } from "./reviewModel";
9import type { ScreenHeaderMenuItem, ScreenHeaderMenu } from "../../components/ScreenHeader.types";
10import type { AppSymbolName } from "../../components/AppSymbol";
11
12interface ReviewHeaderPresentation {
13 readonly title: string;
14 readonly subtitle: string;
15 readonly gitMenu: ScreenHeaderMenu | null;
16 readonly menuIcon: AppSymbolName;
17 readonly refreshAction?: ScreenHeaderMenuItem;
18}
19
20export function useReviewHeaderPresentation(props: {
21 readonly environmentId: EnvironmentId;
22 readonly threadId: ThreadId;
23 readonly title: string;
24 readonly subtitle: string;
25 readonly androidSubtitle: string;
26 readonly selectedThreadCwd: string | null;
27 readonly selectedSection: ReviewSectionItem | null;
28 readonly onRefresh: () => Promise<void>;
29}): ReviewHeaderPresentation {
30 const { selectedThread } = useThreadSelection();
31 const gitState = useSelectedThreadGitState();
32 const gitActions = useSelectedThreadGitActions();
33 const gitStatusQuery = useEnvironmentQuery(
34 selectedThread !== null && props.selectedThreadCwd !== null
35 ? vcsEnvironment.status({
36 environmentId: selectedThread.environmentId,
37 input: { cwd: props.selectedThreadCwd },
38 })
39 : null,
40 );
41 // The selection-based git hooks only apply when this review belongs to the
42 // selected thread (it always does when reached from the thread's toolbar).
43 const gitMenuAvailable =
44 selectedThread !== null && String(selectedThread.id) === String(props.threadId);
45 const gitMenu = useThreadGitMenuDefinition({
46 environmentId: props.environmentId,
47 threadId: props.threadId,
48 currentBranch: selectedThread?.branch ?? null,
49 gitStatus: gitStatusQuery.data,
50 gitOperationLabel: gitState.gitOperationLabel,
51 onPull: gitActions.onPullSelectedThreadBranch,
52 onRunAction: gitActions.onRunSelectedThreadGitAction,
53 });
54 return {
55 title: props.title,
56 subtitle: props.subtitle,
57 menuIcon: "ellipsis",
58 gitMenu: gitMenuAvailable ? gitMenu : null,
59 };
60}