authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 22:33:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 22:33:55-07:00
loge803242d936af3e052757d59ab32bc7c4fcc2390
tree217b58edfca5ec9d2d7241ca8ddbfc2474fbbb65
parent65404eb75de9233c9d1068ddfd09bacfc3b0465b
signature Signed by PGP key B5690EEEBB952194

fix(mobile): restore file viewer insets and glass header (#17073)


9 files changed, 77 insertions(+), 37 deletions(-)

apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift+8
......@@ -49,6 +49,14 @@ public class T3ReviewDiffModule: Module {
4949 view.setContentWidth(CGFloat(contentWidth))
5050 }
5151
52 Prop("contentInsetTop") { (view: T3ReviewDiffView, inset: Double) in
53 view.setContentInsetTop(CGFloat(inset))
54 }
55
56 Prop("contentInsetBottom") { (view: T3ReviewDiffView, inset: Double) in
57 view.setContentInsetBottom(CGFloat(inset))
58 }
59
5260 Prop("initialRowIndex") { (view: T3ReviewDiffView, initialRowIndex: Double) in
5361 view.setInitialRowIndex(initialRowIndex)
5462 }
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+28-8
......@@ -563,6 +563,24 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
563563 ])
564564 }
565565
566 // Source files fill the screen so UIKit can sample content below the glass bar.
567 // Keep the same visible row when header or safe-area reservations change.
568 func setContentInsetTop(_ inset: CGFloat) {
569 scrollView.automaticallyAdjustsScrollIndicatorInsets = false
570 let inset = max(0, inset)
571 let delta = inset - scrollView.contentInset.top
572 guard delta != 0 else { return }
573 scrollView.contentInset.top = inset
574 scrollView.verticalScrollIndicatorInsets.top = inset
575 scrollView.contentOffset.y -= delta
576 updateViewportFrame()
577 }
578
579 func setContentInsetBottom(_ inset: CGFloat) {
580 scrollView.contentInset.bottom = max(0, inset)
581 scrollView.verticalScrollIndicatorInsets.bottom = max(0, inset)
582 }
583
566584 func setContentResetKey(_ contentResetKey: String) {
567585 guard contentResetKey != self.contentResetKey else {
568586 return
......@@ -577,7 +595,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
577595 hasAppliedInitialRowIndex = false
578596 pendingScrollFileId = nil
579597 isProgrammaticScrollActive = false
580 scrollView.setContentOffset(.zero, animated: false)
598 scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: false)
581599 updateContentMetrics()
582600 updateViewportFrame()
583601 lastVisibleFileId = nil
......@@ -870,8 +888,12 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
870888 }
871889
872890 private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) {
873 let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0)
874 let clampedOffset = min(max(targetOffset, 0), maxOffset)
891 let topInset = scrollView.contentInset.top
892 let maxOffset = max(
893 scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom,
894 -topInset
895 )
896 let clampedOffset = min(max(targetOffset - topInset, -topInset), maxOffset)
875897 let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5
876898 isProgrammaticScrollActive = shouldAnimate
877899 contentView.isVerticalScrollActive = shouldAnimate
......@@ -889,12 +911,10 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
889911 return
890912 }
891913
892 let targetScreenY = max(0, (bounds.height - rowFrame.height) * 0.3)
893 let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0)
894 let targetOffset = min(max(rowFrame.minY - targetScreenY, 0), maxOffset)
914 let visibleHeight = bounds.height - scrollView.contentInset.top - scrollView.contentInset.bottom
915 let targetScreenY = max(0, (visibleHeight - rowFrame.height) * 0.3)
895916 hasAppliedInitialRowIndex = true
896 scrollView.setContentOffset(CGPoint(x: 0, y: targetOffset), animated: false)
897 updateViewportFrame()
917 setVerticalContentOffset(rowFrame.minY - targetScreenY, animated: false)
898918 }
899919}
900920
apps/mobile/src/Stack.tsx+2-2
......@@ -159,7 +159,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = {
159159};
160160
161161// SOLID: opaque sheet-colored header for surfaces whose content scrolls internally
162// (file viewer, terminal, review) — there is nothing for glass to sample there.
162// (terminal, review) — there is nothing for glass to sample there.
163163const SOLID_HEADER_OPTIONS: AppScreenOptions = {
164164 headerBackButtonDisplayMode: "minimal",
165165 headerBackTitle: "",
......@@ -750,7 +750,7 @@ const RootStackConfig = createWorkspaceStackNavigator({
750750 ThreadFile: createNativeStackScreen({
751751 screen: ThreadFileScreen,
752752 linking: `${THREAD_LINKING_PREFIX}/files/:path*`,
753 options: SOLID_HEADER_OPTIONS,
753 options: GLASS_HEADER_OPTIONS,
754754 }),
755755 ThreadMcpApp: createNativeStackScreen({
756756 screen: McpAppFullscreenScreen,
apps/mobile/src/components/ComposerContextSheet.tsx+1
......@@ -47,6 +47,7 @@ function ContextField(props: { label: string; value: string | null | undefined;
4747 style={{ height: Math.min(260, Math.max(100, props.value.split("\n").length * 22 + 36)) }}
4848 >
4949 <SourceFileSurface
50 embedded
5051 contents={props.value}
5152 path={props.label === "HTML" ? "element.html" : "styles.css"}
5253 />
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+2
......@@ -121,6 +121,8 @@ export interface NativeReviewDiffViewProps extends ViewProps {
121121 readonly styleJson?: string;
122122 readonly rowHeight: number;
123123 readonly contentWidth: number;
124 readonly contentInsetTop?: number;
125 readonly contentInsetBottom?: number;
124126 readonly initialRowIndex?: number;
125127 readonly refreshing?: boolean;
126128 readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>;
apps/mobile/src/features/files/FileTreeBrowser.tsx+6-18
......@@ -8,7 +8,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
88import { AppText as Text } from "../../components/AppText";
99import { PierreEntryIcon } from "../../components/PierreEntryIcon";
1010import { cn } from "../../lib/cn";
11import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
11import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
1212import {
1313 buildFileTree,
1414 flattenFileTree,
......@@ -42,7 +42,6 @@ const FileTreeRow = memo(function FileTreeRow(props: {
4242 readonly item: VisibleFileTreeNode;
4343 readonly selected: boolean;
4444 readonly expanded: boolean;
45 readonly loaded: boolean;
4645 readonly loading: boolean;
4746 readonly onPressDirectory: (path: string, expand: boolean) => void;
4847 readonly onPreviewFile?: (path: string) => void;
......@@ -98,10 +97,6 @@ const FileTreeRow = memo(function FileTreeRow(props: {
9897 </Text>
9998 {node.kind === "directory" && props.expanded && props.loading ? (
10099 <ActivityIndicator size="small" accessibilityLabel={`Loading ${node.name}`} />
101 ) : node.kind === "directory" && props.loaded ? (
102 <Text className="text-2xs font-t3-medium text-foreground-tertiary">
103 {node.children.length}
104 </Text>
105100 ) : null}
106101 </Pressable>
107102 );
......@@ -115,7 +110,6 @@ export function FileTreeBrowser(props: {
115110 readonly searchQuery: string;
116111 readonly searchTruncated: boolean;
117112 readonly selectedPath: string | null;
118 readonly loadedDirectories: ReadonlySet<string>;
119113 readonly loadingDirectories: ReadonlySet<string>;
120114 readonly onLoadDirectory: (path: string) => void;
121115 readonly onPreviewFile?: (path: string) => void;
......@@ -128,14 +122,12 @@ export function FileTreeBrowser(props: {
128122 readonly selectedPathAtPress: string | null;
129123 } | null>(null);
130124 const insets = useSafeAreaInsets();
131 // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the
132 // observed adjustedContentInset bottom (~102) seen in the native trace.
133 const headerInset = Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT : 0;
125 const columnMetrics = useNativeColumnLayoutMetrics();
126 const headerInset = Platform.OS === "ios" ? (columnMetrics?.safeArea.top ?? insets.top) : 0;
134127 const {
135128 onLoadDirectory,
136129 onPreviewFile,
137130 onSelectFile,
138 loadedDirectories,
139131 loadingDirectories,
140132 selectedPath: controlledSelectedPath,
141133 } = props;
......@@ -226,7 +218,6 @@ export function FileTreeBrowser(props: {
226218 item={item}
227219 selected={item.node.kind === "file" && item.node.path === selectedPath}
228220 expanded={expandedPaths.has(item.node.path)}
229 loaded={loadedDirectories.has(item.node.path)}
230221 loading={loadingDirectories.has(item.node.path)}
231222 onPressDirectory={toggleDirectory}
232223 onPreviewFile={onPreviewFile}
......@@ -237,7 +228,6 @@ export function FileTreeBrowser(props: {
237228 expandedPaths,
238229 handleSelectFile,
239230 onPreviewFile,
240 loadedDirectories,
241231 loadingDirectories,
242232 selectedPath,
243233 toggleDirectory,
......@@ -245,8 +235,8 @@ export function FileTreeBrowser(props: {
245235 );
246236
247237 const extraData = useMemo(
248 () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }),
249 [expandedPaths, loadedDirectories, loadingDirectories, selectedPath],
238 () => ({ expandedPaths, loadingDirectories, selectedPath }),
239 [expandedPaths, loadingDirectories, selectedPath],
250240 );
251241
252242 // UIKit owns the header inset on every supported iOS version. Keep the
......@@ -259,9 +249,7 @@ export function FileTreeBrowser(props: {
259249 data={visibleNodes}
260250 keyExtractor={(item) => item.node.path}
261251 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
262 scrollIndicatorInsets={
263 Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined
264 }
252 automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
265253 keyboardDismissMode="on-drag"
266254 keyboardShouldPersistTaps="handled"
267255 estimatedItemSize={42}
apps/mobile/src/features/files/SourceFileSurface.tsx+23-6
......@@ -11,9 +11,10 @@ import {
1111 useWindowDimensions,
1212 View,
1313} from "react-native";
14
14import { useSafeAreaInsets } from "react-native-safe-area-context";
1515import { AppText as Text } from "../../components/AppText";
1616import { LoadingStrip } from "../../components/LoadingStrip";
17import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
1718import {
1819 type NativeReviewDiffViewProps,
1920 resolveNativeReviewDiffView,
......@@ -39,7 +40,11 @@ import { sourceHighlightAtom } from "./sourceHighlightingState";
3940interface SourceFileSurfaceProps {
4041 readonly contents: string;
4142 readonly path: string;
43 /** The enclosing sheet or card already reserves its own chrome and safe area. */
44 readonly embedded?: boolean;
4245 readonly initialLine?: number | null;
46 /** Override when a fixed notice already reserves the native header. */
47 readonly headerInsetTop?: number;
4348 /** Keep the entire document in one native text-selection scope. */
4449 readonly selectable?: boolean;
4550 /** Enables native pull-to-refresh on the source surface. */
......@@ -184,6 +189,9 @@ function NativeSourceFileSurface(
184189 },
185190) {
186191 const { NativeView, onRefresh } = props;
192 const insets = useSafeAreaInsets();
193 const columnMetrics = useNativeColumnLayoutMetrics();
194 const safeArea = columnMetrics?.safeArea ?? insets;
187195 const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
188196 const { themeAppearance, themeId } = useAppearancePreferences();
189197 const appTheme = useUniwindTheme();
......@@ -214,6 +222,8 @@ function NativeSourceFileSurface(
214222 appearanceScheme={themeAppearance}
215223 contentResetKey={props.path}
216224 contentWidth={contentWidth}
225 contentInsetTop={props.embedded ? 0 : (props.headerInsetTop ?? safeArea.top)}
226 contentInsetBottom={props.embedded ? 0 : safeArea.bottom + 8}
217227 initialRowIndex={targetIndex ?? -1}
218228 rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight}
219229 rowsJson={rowsJson}
......@@ -233,6 +243,7 @@ function NativeSourceFileSurface(
233243}
234244
235245function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
246 const usesAutomaticInsets = Platform.OS === "ios" && !props.embedded;
236247 const foreground = useUniwindTheme()["--color-foreground"];
237248 const { codeSurface, codeWordBreak } = useAppearanceCodeSurface();
238249 const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props);
......@@ -323,6 +334,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
323334 const list = (
324335 <FlatList
325336 ref={listRef}
337 contentInsetAdjustmentBehavior={usesAutomaticInsets ? "automatic" : "never"}
338 automaticallyAdjustsScrollIndicatorInsets={usesAutomaticInsets}
326339 refreshControl={refreshControl}
327340 data={lines}
328341 keyExtractor={(_line, index) => String(index)}
......@@ -354,6 +367,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
354367 <ScrollView
355368 refreshControl={refreshControl}
356369 className="flex-1"
370 contentInsetAdjustmentBehavior={usesAutomaticInsets ? "automatic" : "never"}
371 automaticallyAdjustsScrollIndicatorInsets={usesAutomaticInsets}
357372 contentContainerStyle={{
358373 paddingBottom: codeSurface.rowHeight,
359374 paddingHorizontal: 12,
......@@ -393,9 +408,11 @@ export function SourceFileSurface(props: SourceFileSurfaceProps) {
393408 // The native canvas draws source lines without text selection or wrapping. Attachments
394409 // need one selectable text view in either wrap mode; workspace line navigation can still
395410 // use the canvas when wrapping is disabled.
396 return NativeView && !codeWordBreak && !props.selectable ? (
397 <NativeSourceFileSurface {...props} NativeView={NativeView} />
398 ) : (
399 <JavaScriptSourceFileSurface {...props} />
400 );
411 const surface =
412 NativeView && !codeWordBreak && !props.selectable ? (
413 <NativeSourceFileSurface {...props} NativeView={NativeView} />
414 ) : (
415 <JavaScriptSourceFileSurface {...props} />
416 );
417 return surface;
401418}
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+7-2
......@@ -48,6 +48,7 @@ import { FileTreeBrowser } from "./FileTreeBrowser";
4848import { useFileTreeEntries } from "./useFileTreeEntries";
4949import { preloadWorkspaceFileContents } from "./preload-workspace-file";
5050import { SourceFileSurface } from "./SourceFileSurface";
51import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
5152import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
5253import { ScreenHeader } from "../../components/ScreenHeader";
5354import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
......@@ -229,6 +230,10 @@ function FileContent(props: {
229230}) {
230231 // Reopening a mutable host file must not reuse a poster from an earlier visit.
231232 const thumbnailInstanceId = useId();
233 const insets = useSafeAreaInsets();
234 const columnMetrics = useNativeColumnLayoutMetrics();
235 const reservedHeaderInset =
236 Platform.OS === "ios" && props.truncated ? (columnMetrics?.safeArea.top ?? insets.top) : 0;
232237 const isMarkdown = isMarkdownPreviewFile(props.relativePath);
233238 const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath);
234239 const isImageFile = isWorkspaceImagePreviewPath(props.relativePath);
......@@ -300,7 +305,7 @@ function FileContent(props: {
300305 }
301306
302307 return (
303 <View className="flex-1 bg-sheet">
308 <View className="flex-1 bg-sheet" style={{ paddingTop: reservedHeaderInset }}>
304309 {props.truncated ? (
305310 <FilePreviewNotice title="Partial file">
306311 Preview limited to the first 1 MB of a truncated file.
......@@ -320,6 +325,7 @@ function FileContent(props: {
320325 contents={props.fileContents}
321326 path={props.relativePath}
322327 initialLine={props.initialLine}
328 headerInsetTop={props.truncated ? 0 : undefined}
323329 onRefresh={props.onRefresh}
324330 />
325331 )}
......@@ -561,7 +567,6 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
561567 <FileTreeBrowser
562568 key={JSON.stringify([environmentId, cwd])}
563569 entries={entriesQuery.entries}
564 loadedDirectories={entriesQuery.loadedDirectories}
565570 loadingDirectories={entriesQuery.loadingDirectories}
566571 onLoadDirectory={entriesQuery.loadDirectory}
567572 error={entriesQuery.error}
apps/mobile/src/features/files/thread-file-navigator-pane.tsx-1
......@@ -87,7 +87,6 @@ export function ThreadFileNavigatorPane(props: {
8787 <FileTreeBrowser
8888 key={JSON.stringify([props.environmentId, props.cwd])}
8989 entries={entriesQuery.entries}
90 loadedDirectories={entriesQuery.loadedDirectories}
9190 loadingDirectories={entriesQuery.loadingDirectories}
9291 onLoadDirectory={entriesQuery.loadDirectory}
9392 error={