| ... | ... | @@ -1,11 +1,10 @@ |
| 1 | 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | | import type { MenuAction } from "@react-native-menu/menu"; |
| 3 | 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 4 | | import { |
| 5 | | NativeHeaderToolbar, |
| 6 | | NativeStackScreenOptions, |
| 7 | | nativeHeaderScrollEdgeEffects, |
| 8 | | } from "../../native/StackHeader"; |
| 3 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 4 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 5 | import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types"; |
| 6 | import type { ReviewSectionItem } from "./reviewModel"; |
| 7 | import { useReviewHeaderPresentation } from "./useReviewHeaderPresentation"; |
| 9 | 8 | import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; |
| 10 | 9 | import { |
| 11 | 10 | memo, |
| ... | ... | @@ -33,8 +32,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 33 | 32 | |
| 34 | 33 | import { AppText as Text } from "../../components/AppText"; |
| 35 | 34 | import { SymbolView } from "../../components/AppSymbol"; |
| 36 | | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 37 | | import { ControlPillMenu } from "../../components/ControlPill"; |
| 35 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 38 | 36 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; |
| 39 | 37 | import { cn } from "../../lib/cn"; |
| 40 | 38 | import { environmentCatalog } from "../../connection/catalog"; |
| ... | ... | @@ -50,17 +48,7 @@ import { |
| 50 | 48 | useAdaptiveWorkspacePaneRole, |
| 51 | 49 | useRegisterWorkspaceInspector, |
| 52 | 50 | } from "../layout/AdaptiveWorkspaceLayout"; |
| 53 | | import { useEnvironmentQuery } from "../../state/query"; |
| 54 | | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; |
| 55 | | import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; |
| 56 | 51 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; |
| 57 | | import { useThreadSelection } from "../../state/use-thread-selection"; |
| 58 | | import { vcsEnvironment } from "../../state/vcs"; |
| 59 | | import { |
| 60 | | AndroidWorkspaceSidebarButton, |
| 61 | | WorkspaceSidebarToolbar, |
| 62 | | } from "../layout/workspace-sidebar-toolbar"; |
| 63 | | import { ThreadGitMenu } from "../threads/ThreadGitControls"; |
| 64 | 52 | import { useReviewCacheForThread } from "./reviewState"; |
| 65 | 53 | import { |
| 66 | 54 | isNativeReviewDiffDrawEvent, |
| ... | ... | @@ -78,9 +66,96 @@ import { useReviewCommentSelectionController } from "./useReviewCommentSelection |
| 78 | 66 | import { resolveReviewAvailability } from "./reviewAvailability"; |
| 79 | 67 | import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; |
| 80 | 68 | import { buildReviewSectionMenu } from "./review-section-menu"; |
| 81 | | import type { ReviewSectionItem } from "./reviewModel"; |
| 82 | 69 | import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal"; |
| 83 | 70 | |
| 71 | function 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 | |
| 84 | 159 | const REVIEW_HEADER_SPACING = 0; |
| 85 | 160 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; |
| 86 | 161 | |
| ... | ... | @@ -370,10 +445,9 @@ type ReviewSheetProps = StaticScreenProps<{ |
| 370 | 445 | }>; |
| 371 | 446 | |
| 372 | 447 | export function ReviewSheet(props: ReviewSheetProps) { |
| 373 | | const isAndroid = Platform.OS === "android"; |
| 374 | 448 | const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); |
| 375 | 449 | useAdaptiveWorkspacePaneRole("inspector"); |
| 376 | | const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 450 | const { panes, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 377 | 451 | const navigation = useNavigation(); |
| 378 | 452 | const insets = useSafeAreaInsets(); |
| 379 | 453 | const { themeAppearance: selectedTheme } = useAppearancePreferences(); |
| ... | ... | @@ -384,23 +458,7 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 384 | 458 | const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; |
| 385 | 459 | const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); |
| 386 | 460 | const reviewCache = useReviewCacheForThread({ environmentId, threadId }); |
| 387 | | /* ─── Git actions for the toolbar menu (commit/push without leaving review) ── */ |
| 388 | | const { selectedThread } = useThreadSelection(); |
| 389 | 461 | 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); |
| 404 | 462 | // With a solid (non-overlay) header the content lays out below the header |
| 405 | 463 | // natively, so no manual top inset is needed. (Android renders its own |
| 406 | 464 | // in-flow AndroidScreenHeader, so it needs no inset either.) |
| ... | ... | @@ -581,52 +639,6 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 581 | 639 | hasCachedSelectedDiff, |
| 582 | 640 | hasAnyCachedDiff, |
| 583 | 641 | }); |
| 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 | | ); |
| 630 | 642 | const handleRetryEnvironment = useCallback(() => { |
| 631 | 643 | void retryEnvironment(environmentId); |
| 632 | 644 | }, [environmentId, retryEnvironment]); |
| ... | ... | @@ -703,147 +715,23 @@ export function ReviewSheet(props: ReviewSheetProps) { |
| 703 | 715 | |
| 704 | 716 | return ( |
| 705 | 717 | <> |
| 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} |
| 722 | 733 | /> |
| 723 | 734 | |
| 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 | | |
| 847 | 735 | <MaterialScreenContent> |
| 848 | 736 | <View className={Platform.OS === "android" ? "flex-1 bg-sheet-solid" : "flex-1 bg-sheet"}> |
| 849 | 737 | {showConnectionNotice ? ( |