authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 23:15:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 23:15:05-07:00
log97a65a3121c11801a877897928efc51ef40fa49f
tree6e07cb07aab139199f216316c0358f09b3f4dd35
parent57f96e69d39669f47e7310062222cd67adbb4912
signature Signed by PGP key B5690EEEBB952194

fix(mobile): align diff scrolling with glass headers (#17085)


3 files changed, 32 insertions(+), 25 deletions(-)

apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+18-11
......@@ -563,7 +563,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
563563 ])
564564 }
565565
566 // Source files fill the screen so UIKit can sample content below the glass bar.
566 // Code surfaces fill the screen so UIKit can sample content below the glass bar.
567567 // Keep the same visible row when header or safe-area reservations change.
568568 func setContentInsetTop(_ inset: CGFloat) {
569569 scrollView.automaticallyAdjustsScrollIndicatorInsets = false
......@@ -571,6 +571,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
571571 let delta = inset - scrollView.contentInset.top
572572 guard delta != 0 else { return }
573573 scrollView.contentInset.top = inset
574 contentView.topContentInset = inset
574575 scrollView.verticalScrollIndicatorInsets.top = inset
575576 scrollView.contentOffset.y -= delta
576577 updateViewportFrame()
......@@ -618,8 +619,12 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
618619 if let scrollAnchor,
619620 let headerOffset = contentView.fileHeaderOffset(forFileId: scrollAnchor.fileId) {
620621 let targetOffset = headerOffset - scrollAnchor.screenY
621 let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0)
622 let clampedOffset = min(max(targetOffset, 0), maxOffset)
622 let minOffset = -scrollView.contentInset.top
623 let maxOffset = max(
624 scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom,
625 minOffset
626 )
627 let clampedOffset = min(max(targetOffset, minOffset), maxOffset)
623628 scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: false)
624629 updateViewportFrame()
625630 }
......@@ -735,7 +740,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
735740 // The top of the combined diff is the explicit "All files" destination.
736741 // Treat it as a first-class selection instead of immediately resolving the
737742 // first file header and undoing the navigator's optimistic selection.
738 if scrollView.contentOffset.y <= 0.5 {
743 let visibleTop = scrollView.contentOffset.y + scrollView.contentInset.top
744 if visibleTop <= 0.5 {
739745 guard lastVisibleFileId != nil else {
740746 return
741747 }
......@@ -744,7 +750,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
744750 return
745751 }
746752
747 guard let fileId = contentView.visibleFileId(atVerticalOffset: scrollView.contentOffset.y),
753 guard let fileId = contentView.visibleFileId(atVerticalOffset: visibleTop),
748754 fileId != lastVisibleFileId else {
749755 return
750756 }
......@@ -1025,6 +1031,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
10251031 }
10261032 }
10271033 var verticalOffset: CGFloat = 0
1034 var topContentInset: CGFloat = 0
10281035 var theme = ReviewDiffNativeTheme.resolve("light") {
10291036 didSet {
10301037 tokenColorsByHex.removeAll()
......@@ -1495,7 +1502,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
14951502
14961503 let rowIndex = stickyHeader.rowIndex
14971504 let headerTop = rowOffsets[rowIndex]
1498 guard headerTop < verticalOffset else {
1505 guard headerTop < verticalOffset + topContentInset else {
14991506 return nil
15001507 }
15011508
......@@ -1505,12 +1512,12 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
15051512 : nil
15061513 let pushedY: CGFloat
15071514 if let nextHeaderRowIndex {
1508 pushedY = min(0, rowOffsets[nextHeaderRowIndex] - verticalOffset - style.fileHeaderHeight)
1515 pushedY = min(topContentInset, rowOffsets[nextHeaderRowIndex] - verticalOffset - style.fileHeaderHeight)
15091516 } else {
1510 pushedY = 0
1517 pushedY = topContentInset
15111518 }
15121519
1513 guard pushedY > -style.fileHeaderHeight else {
1520 guard pushedY > topContentInset - style.fileHeaderHeight else {
15141521 return nil
15151522 }
15161523
......@@ -1533,7 +1540,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
15331540 while lowerBound < upperBound {
15341541 let midpoint = (lowerBound + upperBound) / 2
15351542 let rowIndex = fileHeaderRowIndices[midpoint]
1536 if rowOffsets[rowIndex] <= verticalOffset {
1543 if rowOffsets[rowIndex] <= verticalOffset + topContentInset {
15371544 lowerBound = midpoint + 1
15381545 } else {
15391546 upperBound = midpoint
......@@ -1550,7 +1557,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
15501557 func scrollAnchor(forFileId fileId: String) -> ReviewDiffScrollAnchor? {
15511558 if let stickyHeader = stickyFileHeaderTarget(),
15521559 resolvedFileId(for: stickyHeader.row) == fileId {
1553 return ReviewDiffScrollAnchor(fileId: fileId, screenY: 0)
1560 return ReviewDiffScrollAnchor(fileId: fileId, screenY: topContentInset)
15541561 }
15551562
15561563 guard let headerOffset = fileHeaderOffset(forFileId: fileId) else {
apps/mobile/src/Stack.tsx+2-3
......@@ -158,8 +158,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
158158 unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined,
159159};
160160
161// SOLID: opaque sheet-colored header for surfaces whose content scrolls internally
162// (terminal, review) — there is nothing for glass to sample there.
161// SOLID: opaque sheet-colored header for surfaces that manage their own viewport.
163162const SOLID_HEADER_OPTIONS: AppScreenOptions = {
164163 headerBackButtonDisplayMode: "minimal",
165164 headerBackTitle: "",
......@@ -724,7 +723,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
724723 ThreadReview: createNativeStackScreen({
725724 screen: ReviewSheet,
726725 linking: `${THREAD_LINKING_PREFIX}/review`,
727 options: SOLID_HEADER_OPTIONS,
726 options: GLASS_HEADER_OPTIONS,
728727 }),
729728 ThreadReviewComment: createNativeStackScreen({
730729 screen: ReviewCommentComposerSheet,
apps/mobile/src/features/review/ReviewSheet.tsx+12-11
......@@ -29,6 +29,7 @@ import {
2929 View,
3030} from "react-native";
3131import { useSafeAreaInsets } from "react-native-safe-area-context";
32import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
3233
3334import { AppText as Text } from "../../components/AppText";
3435import { SymbolView } from "../../components/AppSymbol";
......@@ -156,7 +157,6 @@ function ReviewHeader(
156157 );
157158}
158159
159const REVIEW_HEADER_SPACING = 0;
160160const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
161161
162162const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
......@@ -461,10 +461,11 @@ export function ReviewSheet(props: ReviewSheetProps) {
461461 const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
462462 const reviewCache = useReviewCacheForThread({ environmentId, threadId });
463463 const { selectedThreadCwd } = useSelectedThreadWorktree();
464 // With a solid (non-overlay) header the content lays out below the header
465 // natively, so no manual top inset is needed. (Android renders its own
466 // in-flow AndroidScreenHeader, so it needs no inset either.)
467 const topContentInset = 0;
464 const columnMetrics = useNativeColumnLayoutMetrics();
465 const safeArea = columnMetrics?.safeArea ?? insets;
466 const topContentInset = Platform.OS === "ios" ? safeArea.top : 0;
467 const bottomContentInset =
468 Platform.OS === "ios" ? safeArea.bottom + 8 : Math.max(insets.bottom, 18) + 18;
468469
469470 useEffect(() => {
470471 showAuxiliaryPane("inspector");
......@@ -760,7 +761,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
760761 >
761762 <View
762763 className="min-w-0 flex-1"
763 style={{ paddingTop: topContentInset + REVIEW_HEADER_SPACING }}
764 style={listHeader ? { paddingTop: topContentInset } : undefined}
764765 >
765766 {listHeader}
766767 <View className="min-w-0 flex-1" collapsable={false}>
......@@ -777,6 +778,8 @@ export function ReviewSheet(props: ReviewSheetProps) {
777778 collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson}
778779 contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`}
779780 contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH}
781 contentInsetTop={listHeader ? 0 : topContentInset}
782 contentInsetBottom={bottomContentInset}
780783 nativeViewRef={nativeReviewDiffViewRef}
781784 rowHeight={nativeReviewDiffStyle.rowHeight}
782785 rowsJson={nativeBridge.rowsJson}
......@@ -804,12 +807,10 @@ export function ReviewSheet(props: ReviewSheetProps) {
804807 : undefined
805808 }
806809 contentInsetAdjustmentBehavior="never"
807 contentInset={{ top: topContentInset, bottom: Math.max(insets.bottom, 18) + 18 }}
810 contentInset={{ top: topContentInset, bottom: bottomContentInset }}
808811 contentOffset={{ x: 0, y: -topContentInset }}
809 scrollIndicatorInsets={{
810 top: topContentInset,
811 bottom: Math.max(insets.bottom, 18) + 18,
812 }}
812 automaticallyAdjustsScrollIndicatorInsets={false}
813 scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }}
813814 showsVerticalScrollIndicator={false}
814815 className="flex-1"
815816 refreshControl={