diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index 96a1785f9fc644ad945bdae6c17a5ee1e6d0dd67..5564dce34af30cd3901afbdfd242983b1d1feabf 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -49,6 +49,14 @@ public class T3ReviewDiffModule: Module { view.setContentWidth(CGFloat(contentWidth)) } + Prop("contentInsetTop") { (view: T3ReviewDiffView, inset: Double) in + view.setContentInsetTop(CGFloat(inset)) + } + + Prop("contentInsetBottom") { (view: T3ReviewDiffView, inset: Double) in + view.setContentInsetBottom(CGFloat(inset)) + } + Prop("initialRowIndex") { (view: T3ReviewDiffView, initialRowIndex: Double) in view.setInitialRowIndex(initialRowIndex) } diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 928e3b5669d1840c44f5978eca7f496c475047e5..dfddaf7016c364736629d8c9059311d6bace0d0f 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -563,6 +563,24 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { ]) } + // Source files fill the screen so UIKit can sample content below the glass bar. + // Keep the same visible row when header or safe-area reservations change. + func setContentInsetTop(_ inset: CGFloat) { + scrollView.automaticallyAdjustsScrollIndicatorInsets = false + let inset = max(0, inset) + let delta = inset - scrollView.contentInset.top + guard delta != 0 else { return } + scrollView.contentInset.top = inset + scrollView.verticalScrollIndicatorInsets.top = inset + scrollView.contentOffset.y -= delta + updateViewportFrame() + } + + func setContentInsetBottom(_ inset: CGFloat) { + scrollView.contentInset.bottom = max(0, inset) + scrollView.verticalScrollIndicatorInsets.bottom = max(0, inset) + } + func setContentResetKey(_ contentResetKey: String) { guard contentResetKey != self.contentResetKey else { return @@ -577,7 +595,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { hasAppliedInitialRowIndex = false pendingScrollFileId = nil isProgrammaticScrollActive = false - scrollView.setContentOffset(.zero, animated: false) + scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: false) updateContentMetrics() updateViewportFrame() lastVisibleFileId = nil @@ -870,8 +888,12 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) { - let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) - let clampedOffset = min(max(targetOffset, 0), maxOffset) + let topInset = scrollView.contentInset.top + let maxOffset = max( + scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom, + -topInset + ) + let clampedOffset = min(max(targetOffset - topInset, -topInset), maxOffset) let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5 isProgrammaticScrollActive = shouldAnimate contentView.isVerticalScrollActive = shouldAnimate @@ -889,12 +911,10 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { return } - let targetScreenY = max(0, (bounds.height - rowFrame.height) * 0.3) - let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) - let targetOffset = min(max(rowFrame.minY - targetScreenY, 0), maxOffset) + let visibleHeight = bounds.height - scrollView.contentInset.top - scrollView.contentInset.bottom + let targetScreenY = max(0, (visibleHeight - rowFrame.height) * 0.3) hasAppliedInitialRowIndex = true - scrollView.setContentOffset(CGPoint(x: 0, y: targetOffset), animated: false) - updateViewportFrame() + setVerticalContentOffset(rowFrame.minY - targetScreenY, animated: false) } } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b215ba3380700db97bf266d2c972c9ab9f203eea..569bfc11ada9ad6a7f4777a73fe3564656d5a1ec 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -159,7 +159,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { }; // SOLID: opaque sheet-colored header for surfaces whose content scrolls internally -// (file viewer, terminal, review) — there is nothing for glass to sample there. +// (terminal, review) — there is nothing for glass to sample there. const SOLID_HEADER_OPTIONS: AppScreenOptions = { headerBackButtonDisplayMode: "minimal", headerBackTitle: "", @@ -750,7 +750,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ThreadFile: createNativeStackScreen({ screen: ThreadFileScreen, linking: `${THREAD_LINKING_PREFIX}/files/:path*`, - options: SOLID_HEADER_OPTIONS, + options: GLASS_HEADER_OPTIONS, }), ThreadMcpApp: createNativeStackScreen({ screen: McpAppFullscreenScreen, diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 208c15268ec1d3b34d0394b88ac9689842cb37f7..4fbc02ab1e4924fbd9a80d7f66aca5fb45b81546 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -47,6 +47,7 @@ function ContextField(props: { label: string; value: string | null | undefined; style={{ height: Math.min(260, Math.max(100, props.value.split("\n").length * 22 + 36)) }} > diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index fda73410668731e282c8a4895462dbf12d751946..16208a7d5a2dd90d1291e23c48f2ae40955b00ec 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -121,6 +121,8 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly styleJson?: string; readonly rowHeight: number; readonly contentWidth: number; + readonly contentInsetTop?: number; + readonly contentInsetBottom?: number; readonly initialRowIndex?: number; readonly refreshing?: boolean; readonly nativeViewRef?: Ref; diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index ed42f5345abe7ef7eea3f6f360415624dda47d52..4d313b47b49fcb851c58b95898f78fa1ac2d8129 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -8,7 +8,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; -import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { buildFileTree, flattenFileTree, @@ -42,7 +42,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { readonly item: VisibleFileTreeNode; readonly selected: boolean; readonly expanded: boolean; - readonly loaded: boolean; readonly loading: boolean; readonly onPressDirectory: (path: string, expand: boolean) => void; readonly onPreviewFile?: (path: string) => void; @@ -98,10 +97,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { {node.kind === "directory" && props.expanded && props.loading ? ( - ) : node.kind === "directory" && props.loaded ? ( - - {node.children.length} - ) : null} ); @@ -115,7 +110,6 @@ export function FileTreeBrowser(props: { readonly searchQuery: string; readonly searchTruncated: boolean; readonly selectedPath: string | null; - readonly loadedDirectories: ReadonlySet; readonly loadingDirectories: ReadonlySet; readonly onLoadDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; @@ -128,14 +122,12 @@ export function FileTreeBrowser(props: { readonly selectedPathAtPress: string | null; } | null>(null); const insets = useSafeAreaInsets(); - // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the - // observed adjustedContentInset bottom (~102) seen in the native trace. - const headerInset = Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT : 0; + const columnMetrics = useNativeColumnLayoutMetrics(); + const headerInset = Platform.OS === "ios" ? (columnMetrics?.safeArea.top ?? insets.top) : 0; const { onLoadDirectory, onPreviewFile, onSelectFile, - loadedDirectories, loadingDirectories, selectedPath: controlledSelectedPath, } = props; @@ -226,7 +218,6 @@ export function FileTreeBrowser(props: { item={item} selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} - loaded={loadedDirectories.has(item.node.path)} loading={loadingDirectories.has(item.node.path)} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} @@ -237,7 +228,6 @@ export function FileTreeBrowser(props: { expandedPaths, handleSelectFile, onPreviewFile, - loadedDirectories, loadingDirectories, selectedPath, toggleDirectory, @@ -245,8 +235,8 @@ export function FileTreeBrowser(props: { ); const extraData = useMemo( - () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }), - [expandedPaths, loadedDirectories, loadingDirectories, selectedPath], + () => ({ expandedPaths, loadingDirectories, selectedPath }), + [expandedPaths, loadingDirectories, selectedPath], ); // UIKit owns the header inset on every supported iOS version. Keep the @@ -259,9 +249,7 @@ export function FileTreeBrowser(props: { data={visibleNodes} keyExtractor={(item) => item.node.path} contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} - scrollIndicatorInsets={ - Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined - } + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" estimatedItemSize={42} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 42b7dbf282d4c2fb659562059ef526e20a77e704..928da8b0f5435dea452ed8d59a16535e46407aeb 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -11,9 +11,10 @@ import { useWindowDimensions, View, } from "react-native"; - +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { type NativeReviewDiffViewProps, resolveNativeReviewDiffView, @@ -39,7 +40,11 @@ import { sourceHighlightAtom } from "./sourceHighlightingState"; interface SourceFileSurfaceProps { readonly contents: string; readonly path: string; + /** The enclosing sheet or card already reserves its own chrome and safe area. */ + readonly embedded?: boolean; readonly initialLine?: number | null; + /** Override when a fixed notice already reserves the native header. */ + readonly headerInsetTop?: number; /** Keep the entire document in one native text-selection scope. */ readonly selectable?: boolean; /** Enables native pull-to-refresh on the source surface. */ @@ -184,6 +189,9 @@ function NativeSourceFileSurface( }, ) { const { NativeView, onRefresh } = props; + const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + const safeArea = columnMetrics?.safeArea ?? insets; const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); const { themeAppearance, themeId } = useAppearancePreferences(); const appTheme = useUniwindTheme(); @@ -214,6 +222,8 @@ function NativeSourceFileSurface( appearanceScheme={themeAppearance} contentResetKey={props.path} contentWidth={contentWidth} + contentInsetTop={props.embedded ? 0 : (props.headerInsetTop ?? safeArea.top)} + contentInsetBottom={props.embedded ? 0 : safeArea.bottom + 8} initialRowIndex={targetIndex ?? -1} rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight} rowsJson={rowsJson} @@ -233,6 +243,7 @@ function NativeSourceFileSurface( } function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { + const usesAutomaticInsets = Platform.OS === "ios" && !props.embedded; const foreground = useUniwindTheme()["--color-foreground"]; const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); @@ -323,6 +334,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const list = ( String(index)} @@ -354,6 +367,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { - ) : ( - - ); + const surface = + NativeView && !codeWordBreak && !props.selectable ? ( + + ) : ( + + ); + return surface; } diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 6c8cac046bb20aa9f18e634f8672d320ef9f81eb..ca9adeaea165093f4522a240ca92ef464bc45d42 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -48,6 +48,7 @@ import { FileTreeBrowser } from "./FileTreeBrowser"; import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { ScreenHeader } from "../../components/ScreenHeader"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; @@ -229,6 +230,10 @@ function FileContent(props: { }) { // Reopening a mutable host file must not reuse a poster from an earlier visit. const thumbnailInstanceId = useId(); + const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + const reservedHeaderInset = + Platform.OS === "ios" && props.truncated ? (columnMetrics?.safeArea.top ?? insets.top) : 0; const isMarkdown = isMarkdownPreviewFile(props.relativePath); const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); @@ -300,7 +305,7 @@ function FileContent(props: { } return ( - + {props.truncated ? ( Preview limited to the first 1 MB of a truncated file. @@ -320,6 +325,7 @@ function FileContent(props: { contents={props.fileContents} path={props.relativePath} initialLine={props.initialLine} + headerInsetTop={props.truncated ? 0 : undefined} onRefresh={props.onRefresh} /> )} @@ -561,7 +567,6 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {