1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
3import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
4import { AppActivityIndicator } from "../../components/AppActivityIndicator";
5import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
6import { ScreenHeader } from "../../components/ScreenHeader";
7import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types";
8import type { ReviewSectionItem } from "./reviewModel";
9import { useReviewHeaderPresentation } from "./useReviewHeaderPresentation";
10import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
11import {
12 memo,
13 type Ref,
14 type ReactElement,
15 useCallback,
16 useEffect,
17 useImperativeHandle,
18 useMemo,
19 useRef,
20 useState,
21} from "react";
22import {
23 FlatList,
24 Platform,
25 Pressable,
26 RefreshControl,
27 ScrollView,
28 type NativeSyntheticEvent,
29 StyleSheet,
30 View,
31} from "react-native";
32import { useSafeAreaInsets } from "react-native-safe-area-context";
33import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
34
35import { AppText as Text } from "../../components/AppText";
36import { SymbolView } from "../../components/AppSymbol";
37import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
38import { MaterialScreenContent } from "../../components/MaterialScreenContent";
39import { cn } from "../../lib/cn";
40import { environmentCatalog } from "../../connection/catalog";
41import { useEnvironmentPresentation } from "../../state/presentation";
42import { useAtomCommand } from "../../state/use-atom-command";
43import { useUniwindTheme } from "../../lib/useUniwindTheme";
44import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
45import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
46import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
47import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
48import {
49 useAdaptiveWorkspaceLayout,
50 useAdaptiveWorkspacePaneRole,
51 useRegisterWorkspaceInspector,
52} from "../layout/AdaptiveWorkspaceLayout";
53import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
54import { useReviewCacheForThread } from "./reviewState";
55import {
56 isNativeReviewDiffDrawEvent,
57 type NativeReviewDiffViewHandle,
58 resolveNativeReviewDiffView,
59} from "../diffs/nativeReviewDiffSurface";
60import { NATIVE_REVIEW_DIFF_CONTENT_WIDTH } from "./nativeReviewDiffAdapter";
61import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
62import { useReviewDiffData } from "./useReviewDiffData";
63import { useReviewDiffPrewarming } from "./useReviewDiffPrewarming";
64import { useReviewFileVisibility } from "./reviewFileVisibility";
65import { useReviewSections } from "./useReviewSections";
66import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge";
67import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController";
68import { resolveReviewAvailability } from "./reviewAvailability";
69import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
70import { buildReviewSectionMenu } from "./review-section-menu";
71import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal";
72
73function ReviewHeader(
74 props: Parameters<typeof useReviewHeaderPresentation>[0] & {
75 readonly iconColor: string;
76 readonly sectionMenu: ReturnType<typeof buildReviewSectionMenu>;
77 readonly showSectionToolbar: boolean;
78 readonly showChangedFilesToggle: boolean;
79 readonly onSelectSection: (sectionId: string) => void;
80 readonly onReturnToThread: () => void;
81 },
82) {
83 const { panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
84 const presentation = useReviewHeaderPresentation(props);
85 const sectionAction = (
86 section: ReviewSectionItem | null,
87 title: string,
88 ): ScreenHeaderMenuItem => ({
89 id: section ? `section:${section.id}` : `unavailable:${title}`,
90 title,
91 disabled: section === null,
92 selected: section !== null && section.id === props.selectedSection?.id,
93 onPress: () => {
94 if (section) props.onSelectSection(section.id);
95 },
96 });
97 return (
98 <ScreenHeader
99 title={presentation.title}
100 subtitle={presentation.subtitle}
101 onBack={props.onReturnToThread}
102 hideBottomBorder
103 options={{ headerTintColor: props.iconColor, headerTitle: props.title }}
104 backInSplitView={{ accessibilityLabel: "Back to chat", icon: "chevron.left" }}
105 actions={
106 props.showChangedFilesToggle
107 ? [
108 {
109 accessibilityLabel: panes.auxiliaryPaneVisible
110 ? "Hide changed files"
111 : "Show changed files",
112 icon: "sidebar.right",
113 selected: panes.auxiliaryPaneVisible,
114 onPress: toggleAuxiliaryPane,
115 },
116 ]
117 : undefined
118 }
119 menus={[
120 ...(presentation.gitMenu ? [presentation.gitMenu] : []),
121 ...(props.showSectionToolbar
122 ? [
123 {
124 title: "Select diff",
125 icon: presentation.menuIcon,
126 items: [
127 {
128 id: "sections",
129 inline: true,
130 items: [
131 sectionAction(props.sectionMenu.branchChanges, "Changes"),
132 sectionAction(props.sectionMenu.workingTree, "Uncommitted"),
133 sectionAction(props.sectionMenu.latestTurn, "Latest turn"),
134 ],
135 },
136 ...(props.sectionMenu.turns.length > 0
137 ? [
138 {
139 id: "turns",
140 title: "Turn",
141 items: props.sectionMenu.turns.map((section) => ({
142 id: `section:${section.id}`,
143 title: section.title,
144 subtitle: section.subtitle ?? undefined,
145 selected: section.id === props.selectedSection?.id,
146 onPress: () => props.onSelectSection(section.id),
147 })),
148 },
149 ]
150 : []),
151 ...(presentation.refreshAction ? [presentation.refreshAction] : []),
152 ],
153 },
154 ]
155 : []),
156 ]}
157 />
158 );
159}
160
161const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
162
163const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
164 return (
165 <View
166 className={cn(
167 "bg-warning px-4 py-3",
168 Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-warning-border",
169 )}
170 >
171 <Text className="text-xs font-t3-bold uppercase text-warning-foreground">Partial diff</Text>
172 <Text className="text-xs leading-normal text-warning-foreground">{props.notice}</Text>
173 </View>
174 );
175});
176
177function ReviewSelectionActionBar(props: {
178 readonly bottomInset: number;
179 readonly title: string | null;
180 readonly onOpenComment: (() => void) | null;
181 readonly onClear: () => void;
182}) {
183 if (!props.title) {
184 return null;
185 }
186
187 const content = (
188 <>
189 <SymbolView
190 name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"}
191 size={16}
192 tintColorClassName="accent-primary-foreground"
193 type="monochrome"
194 />
195 <Text className="text-base font-t3-bold text-primary-foreground">{props.title}</Text>
196 </>
197 );
198
199 return (
200 <View
201 pointerEvents="box-none"
202 style={{
203 position: "absolute",
204 left: 18,
205 right: 18,
206 bottom: Math.max(props.bottomInset, 10) + 18,
207 flexDirection: "row",
208 justifyContent: "center",
209 gap: 10,
210 }}
211 >
212 {props.onOpenComment ? (
213 <Pressable
214 className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-primary px-5"
215 onPress={props.onOpenComment}
216 >
217 {content}
218 </Pressable>
219 ) : (
220 <View className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-primary px-5">
221 {content}
222 </View>
223 )}
224
225 <Pressable
226 className="h-12 w-12 items-center justify-center rounded-full bg-primary"
227 onPress={props.onClear}
228 >
229 <SymbolView
230 name="xmark"
231 size={16}
232 tintColorClassName="accent-primary-foreground"
233 type="monochrome"
234 />
235 </Pressable>
236 </View>
237 );
238}
239
240interface ReviewNavigatorFile {
241 readonly id: string;
242 readonly path: string;
243 readonly additions: number;
244 readonly deletions: number;
245}
246
247const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: {
248 readonly file: ReviewNavigatorFile;
249 readonly selected: boolean;
250 readonly onSelectFile: (fileId: string | null) => void;
251}) {
252 const { file, selected, onSelectFile } = props;
253 // Tapping the selected file again returns to the all-files diff.
254 const handlePress = useCallback(() => {
255 onSelectFile(selected ? null : file.id);
256 }, [file.id, onSelectFile, selected]);
257
258 return (
259 <Pressable
260 accessibilityRole="button"
261 accessibilityState={{ selected }}
262 className={
263 Platform.OS === "android"
264 ? cn(
265 "mt-1 min-h-12 justify-center rounded-[20px] px-3 py-2 active:bg-subtle",
266 selected && "bg-subtle-strong",
267 )
268 : selected
269 ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2"
270 : "mt-1 min-h-12 justify-center rounded-xl px-3 py-2 active:bg-subtle"
271 }
272 onPress={handlePress}
273 >
274 <Text
275 className={
276 selected
277 ? "text-xs font-t3-bold text-foreground"
278 : "text-xs font-t3-medium text-foreground-secondary"
279 }
280 numberOfLines={2}
281 >
282 {file.path}
283 </Text>
284 <View className="mt-1 flex-row gap-2">
285 <Text className="text-2xs font-t3-bold text-adaptive-emerald-700-300">
286 +{formatDiffCount(file.additions)}
287 </Text>
288 <Text className="text-2xs font-t3-bold text-adaptive-rose-700-300">
289 -{formatDiffCount(file.deletions)}
290 </Text>
291 </View>
292 </Pressable>
293 );
294});
295
296interface ReviewFileNavigatorHandle {
297 readonly setVisibleFile: (fileId: string | null) => void;
298}
299
300interface ReviewFileNavigatorProps {
301 readonly files: ReadonlyArray<ReviewNavigatorFile>;
302 readonly headerInset: number;
303 readonly sectionId: string | null;
304 readonly onSelectFile: (fileId: string | null) => void;
305 readonly ref?: Ref<ReviewFileNavigatorHandle>;
306}
307
308function ReviewFileNavigator({
309 files,
310 headerInset,
311 sectionId,
312 onSelectFile,
313 ref,
314}: ReviewFileNavigatorProps) {
315 const insets = useSafeAreaInsets();
316 const theme = useUniwindTheme();
317 const sheetColor = theme["--color-sheet"];
318 const foregroundColor = theme["--color-foreground"];
319 const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
320 const [fileSelection, setFileSelection] = useState<{
321 readonly sectionId: string | null;
322 readonly fileId: string | null;
323 }>({ sectionId: null, fileId: null });
324 const availableFileIds = useMemo(() => files.map((file) => file.id), [files]);
325 const selectedFileId = resolveSelectedReviewFileId({
326 selection: fileSelection,
327 sectionId,
328 availableFileIds,
329 });
330
331 useImperativeHandle(
332 ref,
333 () => ({
334 setVisibleFile: (fileId) => {
335 if (fileId !== null && !availableFileIds.includes(fileId)) {
336 return;
337 }
338 setFileSelection((current) => {
339 if (current.sectionId === sectionId && current.fileId === fileId) {
340 return current;
341 }
342 return { sectionId, fileId };
343 });
344 },
345 }),
346 [availableFileIds, sectionId],
347 );
348
349 const handleSelectFile = useCallback(
350 (fileId: string | null) => {
351 setFileSelection({ sectionId, fileId });
352 onSelectFile(fileId);
353 },
354 [onSelectFile, sectionId],
355 );
356
357 const renderFile = useCallback(
358 ({ item }: { readonly item: ReviewNavigatorFile }) => (
359 <ReviewFileNavigatorRow
360 file={item}
361 selected={selectedFileId === item.id}
362 onSelectFile={handleSelectFile}
363 />
364 ),
365 [handleSelectFile, selectedFileId],
366 );
367
368 const fileList = (
369 <FlatList
370 data={files}
371 extraData={selectedFileId}
372 keyExtractor={(file) => file.id}
373 contentContainerStyle={{
374 paddingHorizontal: 8,
375 paddingBottom: 8,
376 // The nested native header is translucent; start the list below it so
377 // the scroll-edge effect can sample the content (same treatment as
378 // FileTreeBrowser in the Files pane).
379 paddingTop: Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT + 8 : 8,
380 }}
381 scrollIndicatorInsets={
382 Platform.OS === "ios" ? { top: insets.top + IOS_NAV_BAR_HEIGHT } : undefined
383 }
384 renderItem={renderFile}
385 />
386 );
387
388 if (Platform.OS === "ios") {
389 return (
390 <View className="flex-1 border-l border-border bg-sheet">
391 <ScreenStack style={{ flex: 1 }}>
392 <Screen
393 activityState={2}
394 enabled
395 isNativeStack
396 screenId="review-file-navigator-native"
397 scrollEdgeEffects={headerScrollEdgeEffects}
398 style={{ backgroundColor: sheetColor, flex: 1 }}
399 >
400 {fileList}
401 <ScreenStackHeaderConfig
402 backgroundColor="rgba(0,0,0,0)"
403 color={foregroundColor}
404 hideBackButton
405 hideShadow={false}
406 navigationItemStyle="editor"
407 subtitle={`${files.length} ${files.length === 1 ? "file" : "files"}`}
408 title="Changed files"
409 titleColor={foregroundColor}
410 titleFontSize={17}
411 titleFontWeight="700"
412 translucent
413 />
414 </Screen>
415 </ScreenStack>
416 </View>
417 );
418 }
419
420 return (
421 <View
422 className={
423 Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-l border-border bg-sheet"
424 }
425 >
426 {Platform.OS === "android" ? (
427 <AndroidScreenHeader
428 title="Changed files"
429 subtitle={`${files.length} ${files.length === 1 ? "file" : "files"}`}
430 hideBottomBorder
431 />
432 ) : (
433 <View className="border-b border-border" style={{ paddingTop: headerInset }}>
434 <View className="px-4 py-3">
435 <Text className="text-sm font-t3-bold text-foreground">Changed files</Text>
436 <Text className="text-xs text-foreground-muted">
437 {files.length} {files.length === 1 ? "file" : "files"}
438 </Text>
439 </View>
440 </View>
441 )}
442 <MaterialScreenContent insetHorizontal>{fileList}</MaterialScreenContent>
443 </View>
444 );
445}
446
447type ReviewSheetProps = StaticScreenProps<{
448 readonly environmentId: EnvironmentId;
449 readonly threadId: ThreadId;
450}>;
451
452export function ReviewSheet(props: ReviewSheetProps) {
453 const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
454 useAdaptiveWorkspacePaneRole("inspector");
455 const { panes, showAuxiliaryPane } = useAdaptiveWorkspaceLayout();
456 const navigation = useNavigation();
457 const insets = useSafeAreaInsets();
458 const { themeAppearance: selectedTheme } = useAppearancePreferences();
459 const headerIcon = String(useUniwindTheme()["--color-icon"]);
460 const { environmentId, threadId } = props.route.params;
461 const environment = useEnvironmentPresentation(environmentId);
462 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
463 const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
464 const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
465 const reviewCache = useReviewCacheForThread({ environmentId, threadId });
466 const { selectedThreadCwd } = useSelectedThreadWorktree();
467 const columnMetrics = useNativeColumnLayoutMetrics();
468 const safeArea = columnMetrics?.safeArea ?? insets;
469 const topContentInset = Platform.OS === "ios" ? safeArea.top : 0;
470 const bottomContentInset =
471 Platform.OS === "ios" ? safeArea.bottom + 8 : Math.max(insets.bottom, 18) + 18;
472
473 useEffect(() => {
474 showAuxiliaryPane("inspector");
475 }, [environmentId, showAuxiliaryPane, threadId]);
476 const {
477 error,
478 reviewSections,
479 selectedSection,
480 refreshSelectedSection,
481 selectSection,
482 isSelectedSectionPending,
483 diffPreviewRevision,
484 } = useReviewSections({
485 enabled: isEnvironmentReady,
486 environmentId,
487 threadId,
488 reviewCache,
489 });
490 useReviewDiffPrewarming({
491 threadKey: reviewCache.threadKey,
492 sections: reviewSections,
493 selectedSectionId: selectedSection?.id ?? null,
494 });
495 const {
496 headerDiffSummary,
497 nativeReviewDiffData,
498 parsedDiff,
499 pendingReviewCommentCount,
500 loadVisibleFile,
501 isPending: areFilePatchesPending,
502 } = useReviewDiffData({
503 threadKey: reviewCache.threadKey,
504 environmentId,
505 cwd: selectedThreadCwd,
506 selectedSection,
507 revision: diffPreviewRevision,
508 draftMessage,
509 });
510 // Resolution returns null while Expo registers the native view (or forever
511 // when the binary lacks it). Rendering a null component type crashes the
512 // app, so callers must fall back — ThreadFeed's ReviewCommentCard does the
513 // same check.
514 const NativeReviewDiffView = resolveNativeReviewDiffView();
515 const nativeReviewDiffViewRef = useRef<NativeReviewDiffViewHandle>(null);
516 const showcasedReviewDrawRef = useRef<string | null>(null);
517 // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item).
518 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
519 const handlePullToRefresh = useCallback(async () => {
520 setIsPullRefreshing(true);
521 try {
522 await refreshSelectedSection();
523 } finally {
524 setIsPullRefreshing(false);
525 }
526 }, [refreshSelectedSection]);
527 const reviewFileNavigatorRef = useRef<ReviewFileNavigatorHandle>(null);
528 const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : [];
529 const fileVisibility = useReviewFileVisibility({
530 threadKey: reviewCache.threadKey,
531 sectionId: selectedSection?.id ?? null,
532 files: reviewFiles,
533 cachedExpandedFileIds: selectedSection?.id
534 ? reviewCache.expandedFileIdsBySection[selectedSection.id]
535 : undefined,
536 cachedViewedFileIds: selectedSection?.id
537 ? reviewCache.viewedFileIdsBySection[selectedSection.id]
538 : undefined,
539 });
540 const { collapsedFileIds, toggleExpandedFile, toggleViewedFile, viewedFileIds } = fileVisibility;
541 const commentSelection = useReviewCommentSelectionController({
542 environmentId,
543 threadId,
544 selectedSection,
545 nativeReviewDiffData,
546 });
547 const nativeBridge = useNativeReviewDiffBridge({
548 threadKey: reviewCache.threadKey,
549 sectionId: selectedSection?.id ?? null,
550 diff: selectedSection?.diff,
551 data: nativeReviewDiffData,
552 collapsedFileIds,
553 viewedFileIds,
554 selectedRowIds: commentSelection.selectedRowIds,
555 canHighlight: parsedDiff.kind === "files",
556 });
557 const showcaseReviewKey =
558 SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection
559 ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}:${nativeBridge.themeId}`
560 : null;
561 const handleNativeDebug = useCallback(
562 (event: NativeSyntheticEvent<Record<string, unknown>>) => {
563 nativeBridge.onDebug(event);
564 if (
565 showcaseReviewKey === null ||
566 showcasedReviewDrawRef.current === showcaseReviewKey ||
567 !isNativeReviewDiffDrawEvent(event.nativeEvent)
568 ) {
569 return;
570 }
571 showcasedReviewDrawRef.current = showcaseReviewKey;
572 reportShowcaseSceneRendered({ scene: "review", themeId: nativeBridge.themeId });
573 },
574 [nativeBridge.onDebug, nativeBridge.themeId, showcaseReviewKey],
575 );
576
577 const handleSelectFile = useCallback(
578 (fileId: string | null) => {
579 loadVisibleFile(fileId, true);
580 commentSelection.clearSelection();
581 if (fileId !== null && collapsedFileIds.includes(fileId)) {
582 toggleExpandedFile(fileId);
583 }
584 const navigation =
585 fileId === null
586 ? nativeReviewDiffViewRef.current?.scrollToTop(true)
587 : nativeReviewDiffViewRef.current?.scrollToFile(fileId, true);
588 void navigation?.catch((error: unknown) => {
589 console.error("[review] Failed to navigate to diff file", error);
590 });
591 },
592 [collapsedFileIds, commentSelection, toggleExpandedFile, loadVisibleFile],
593 );
594 const handleVisibleFileChange = useCallback(
595 (event: NativeSyntheticEvent<{ readonly fileId?: string | null }>) => {
596 loadVisibleFile(event.nativeEvent.fileId ?? null);
597 reviewFileNavigatorRef.current?.setVisibleFile(event.nativeEvent.fileId ?? null);
598 },
599 [loadVisibleFile],
600 );
601 const renderInspector = useCallback(
602 () => (
603 <ReviewFileNavigator
604 ref={reviewFileNavigatorRef}
605 files={nativeReviewDiffData.files}
606 // The workspace inspector column spans the full window height, so the
607 // pane clears the status bar itself.
608 headerInset={insets.top}
609 sectionId={selectedSection?.id ?? null}
610 onSelectFile={handleSelectFile}
611 />
612 ),
613 [handleSelectFile, insets.top, nativeReviewDiffData.files, selectedSection?.id],
614 );
615
616 const handleNativeToggleFile = useCallback(
617 (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
618 const { fileId } = event.nativeEvent;
619 if (fileId) {
620 loadVisibleFile(fileId, true);
621 toggleExpandedFile(fileId);
622 }
623 },
624 [toggleExpandedFile, loadVisibleFile],
625 );
626
627 const handleNativeToggleViewedFile = useCallback(
628 (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
629 const { fileId } = event.nativeEvent;
630 if (fileId) {
631 toggleViewedFile(fileId);
632 }
633 },
634 [toggleViewedFile],
635 );
636
637 const parsedDiffNotice =
638 parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null;
639 const hasCachedSelectedDiff = selectedSection?.diff != null;
640 const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null);
641 const sectionMenu = useMemo(() => buildReviewSectionMenu(reviewSections), [reviewSections]);
642 const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({
643 hasEnvironmentPresentation: environment.isReady,
644 isEnvironmentConnected: isEnvironmentReady,
645 hasCachedSelectedDiff,
646 hasAnyCachedDiff,
647 });
648 const handleRetryEnvironment = useCallback(() => {
649 void retryEnvironment(environmentId);
650 }, [environmentId, retryEnvironment]);
651 const handleReturnToThread = useCallback(() => {
652 if (navigation.canGoBack()) {
653 navigation.goBack();
654 return;
655 }
656 navigation.navigate("Thread", {
657 environmentId: String(environmentId),
658 threadId: String(threadId),
659 });
660 }, [environmentId, navigation, threadId]);
661 const androidHeaderSubtitle = [
662 selectedSection?.title,
663 headerDiffSummary.additions,
664 headerDiffSummary.deletions,
665 ]
666 .filter((part): part is string => Boolean(part))
667 .join(" · ");
668
669 // The changed-files navigator drives the native diff surface via
670 // scrollToFile, so it is only useful when that surface resolved. In raw
671 // fallback mode the ref is necessarily null and the raw patch neither
672 // scrolls nor filters — registering the navigator would present working
673 // controls that cannot navigate.
674 const showChangedFilesPane =
675 !showConnectionNotice &&
676 selectedSection !== null &&
677 parsedDiff.kind === "files" &&
678 NativeReviewDiffView !== null;
679 useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined);
680 // A toggle needs registered content; loading, errors and raw patches have no navigator pane.
681 const showChangedFilesToggle = panes.supportsAuxiliaryPane && showChangedFilesPane;
682
683 const listHeader = useMemo(() => {
684 const children: ReactElement[] = [];
685
686 if (error) {
687 children.push(
688 <View
689 key="review-error"
690 className={cn(
691 "bg-card px-4 py-3",
692 Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border",
693 )}
694 >
695 <Text className="text-sm font-t3-bold text-foreground">Review unavailable</Text>
696 <Text className="text-xs leading-normal text-foreground-muted">{error}</Text>
697 </View>,
698 );
699 }
700
701 if (parsedDiffNotice) {
702 children.push(<ReviewNotice key="review-notice" notice={parsedDiffNotice} />);
703 }
704
705 if (children.length === 0) {
706 return null;
707 }
708
709 return <>{children}</>;
710 }, [error, parsedDiffNotice]);
711 const headerSubtitle = [
712 headerDiffSummary.additions,
713 headerDiffSummary.deletions,
714 pendingReviewCommentCount > 0
715 ? `${pendingReviewCommentCount} comment${pendingReviewCommentCount === 1 ? "" : "s"}`
716 : null,
717 ]
718 .filter(Boolean)
719 .join(" · ");
720 const headerTitleText = selectedSection?.title ?? "Review changes";
721
722 return (
723 <>
724 <ReviewHeader
725 environmentId={environmentId}
726 threadId={threadId}
727 title={headerTitleText}
728 subtitle={headerSubtitle}
729 androidSubtitle={androidHeaderSubtitle}
730 iconColor={headerIcon}
731 selectedThreadCwd={selectedThreadCwd}
732 sectionMenu={sectionMenu}
733 selectedSection={selectedSection}
734 showSectionToolbar={showSectionToolbar}
735 showChangedFilesToggle={showChangedFilesToggle}
736 onRefresh={handlePullToRefresh}
737 onSelectSection={selectSection}
738 onReturnToThread={handleReturnToThread}
739 />
740
741 <MaterialScreenContent>
742 <View className="flex-1 bg-sheet android:bg-sheet-solid">
743 {showConnectionNotice ? (
744 <View className="flex-1" style={{ paddingTop: topContentInset }}>
745 <EnvironmentConnectionNotice
746 environmentLabel={environment.presentation?.entry.target.label ?? "Environment"}
747 connection={
748 environment.presentation?.connection ?? {
749 phase: "available",
750 error: null,
751 traceId: null,
752 }
753 }
754 resourceName="review"
755 onRetry={handleRetryEnvironment}
756 />
757 </View>
758 ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? (
759 <View
760 className="flex-1"
761 style={{
762 backgroundColor: nativeBridge.theme.background,
763 }}
764 >
765 <View
766 className="min-w-0 flex-1"
767 style={listHeader ? { paddingTop: topContentInset } : undefined}
768 >
769 {listHeader}
770 <View className="min-w-0 flex-1" collapsable={false}>
771 <NativeReviewDiffView
772 collapsable={false}
773 testID="review-native-diff-view"
774 refreshing={
775 isPullRefreshing || isSelectedSectionPending || areFilePatchesPending
776 }
777 onPullToRefresh={() => void handlePullToRefresh()}
778 style={StyleSheet.absoluteFill}
779 appearanceScheme={selectedTheme}
780 collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson}
781 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
782 contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`}
783 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
784 contentInsetTop={listHeader ? 0 : topContentInset}
785 contentInsetBottom={bottomContentInset}
786 nativeViewRef={nativeReviewDiffViewRef}
787 rowHeight={nativeReviewDiffStyle.rowHeight}
788 rowsJson={nativeBridge.rowsJson}
789 selectedRowIdsJson={nativeBridge.selectedRowIdsJson}
790 styleJson={nativeBridge.styleJson}
791 themeJson={nativeBridge.themeJson}
792 tokensPatchJson={nativeBridge.tokensPatchJson}
793 tokensResetKey={nativeBridge.tokensResetKey}
794 viewedFileIdsJson={nativeBridge.viewedFileIdsJson}
795 onDebug={handleNativeDebug}
796 onPressLine={commentSelection.onPressLine}
797 onVisibleFileChange={handleVisibleFileChange}
798 onToggleComment={nativeBridge.onToggleComment}
799 onToggleFile={handleNativeToggleFile}
800 onToggleViewedFile={handleNativeToggleViewedFile}
801 />
802 </View>
803 </View>
804 </View>
805 ) : (
806 <ScrollView
807 contentContainerStyle={
808 Platform.OS === "android" && (parsedDiff.kind === "empty" || !selectedSection)
809 ? { flexGrow: 1, justifyContent: "center" }
810 : undefined
811 }
812 contentInsetAdjustmentBehavior="never"
813 contentInset={{ top: topContentInset, bottom: bottomContentInset }}
814 contentOffset={{ x: 0, y: -topContentInset }}
815 automaticallyAdjustsScrollIndicatorInsets={false}
816 scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }}
817 showsVerticalScrollIndicator={false}
818 className="flex-1"
819 refreshControl={
820 // The native diff surface owns pull-to-refresh via onPullToRefresh;
821 // the raw fallback (and empty states) need an explicit control —
822 // iOS has no other refresh affordance here (the explicit
823 // "Refresh current diff" menu is Android-only).
824 <RefreshControl
825 refreshing={isPullRefreshing || isSelectedSectionPending || areFilePatchesPending}
826 onRefresh={() => void handlePullToRefresh()}
827 />
828 }
829 >
830 {listHeader}
831 {!selectedSection ? (
832 error ? null : (
833 <View
834 className={
835 Platform.OS === "android"
836 ? "items-center px-6 py-5"
837 : "border-b border-border bg-card px-4 py-5"
838 }
839 >
840 <Text className="text-sm font-t3-bold text-foreground">No review diffs</Text>
841 <Text
842 className={cn(
843 "text-xs leading-normal text-foreground-muted",
844 Platform.OS === "android" && "mt-2 text-center",
845 )}
846 >
847 This thread has no ready turn diffs and the worktree diff is empty.
848 </Text>
849 </View>
850 )
851 ) : selectedSection.isLoading && selectedSection.diff === null ? (
852 <View
853 className={cn(
854 "items-center gap-3 px-4 py-6",
855 Platform.OS !== "android" && "border-b border-border bg-card",
856 )}
857 >
858 <AppActivityIndicator size="small" />
859 <Text className="text-xs text-foreground-muted">Loading diff…</Text>
860 </View>
861 ) : parsedDiff.kind === "empty" ? (
862 <View
863 className={
864 Platform.OS === "android"
865 ? "items-center px-6 py-5"
866 : "border-b border-border bg-card px-4 py-5"
867 }
868 >
869 <Text className="text-sm font-t3-bold text-foreground">No changes</Text>
870 <Text
871 className={cn(
872 "text-xs leading-normal text-foreground-muted",
873 Platform.OS === "android" && "mt-2 text-center",
874 )}
875 >
876 {selectedSection.subtitle ?? "This diff is empty."}
877 </Text>
878 </View>
879 ) : parsedDiff.kind === "raw" ? (
880 <View
881 className={cn(
882 "gap-3 bg-card px-4 py-4",
883 Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border",
884 )}
885 >
886 <Text className="text-xs leading-normal text-foreground-muted">
887 {parsedDiff.reason}
888 </Text>
889 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
890 <Text selectable className="font-mono text-xs leading-relaxed text-foreground">
891 {parsedDiff.text}
892 </Text>
893 </ScrollView>
894 </View>
895 ) : parsedDiff.kind === "files" ? (
896 // The native diff surface could not be resolved on this binary;
897 // degrade to the raw patch instead of crashing the app.
898 <View
899 className={cn(
900 "gap-3 bg-card px-4 py-4",
901 Platform.OS === "android" ? "m-2 rounded-[20px]" : "border-b border-border",
902 )}
903 >
904 <Text className="text-xs leading-normal text-foreground-muted">
905 Native diff view unavailable. Showing the raw patch.
906 </Text>
907 <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}>
908 <Text selectable className="font-mono text-xs leading-relaxed text-foreground">
909 {selectedSection?.diff ?? ""}
910 </Text>
911 </ScrollView>
912 </View>
913 ) : null}
914 </ScrollView>
915 )}
916 <ReviewSelectionActionBar
917 bottomInset={insets.bottom}
918 title={commentSelection.selectionAction?.title ?? null}
919 onOpenComment={commentSelection.selectionAction?.onOpenComment ?? null}
920 onClear={commentSelection.clearSelection}
921 />
922 </View>
923 </MaterialScreenContent>
924 </>
925 );
926}