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) {