| author | |
| committer | |
| log | e803242d936af3e052757d59ab32bc7c4fcc2390 |
| tree | 217b58edfca5ec9d2d7241ca8ddbfc2474fbbb65 |
| parent | 65404eb75de9233c9d1068ddfd09bacfc3b0465b |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 49 | 49 | view.setContentWidth(CGFloat(contentWidth)) |
| 50 | 50 | } |
| 51 | 51 | |
| 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 | ||
| 52 | 60 | Prop("initialRowIndex") { (view: T3ReviewDiffView, initialRowIndex: Double) in |
| 53 | 61 | view.setInitialRowIndex(initialRowIndex) |
| 54 | 62 | } |
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+28-8| ... | ... | @@ -563,6 +563,24 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 563 | 563 | ]) |
| 564 | 564 | } |
| 565 | 565 | |
| 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 | ||
| 566 | 584 | func setContentResetKey(_ contentResetKey: String) { |
| 567 | 585 | guard contentResetKey != self.contentResetKey else { |
| 568 | 586 | return |
| ... | ... | @@ -577,7 +595,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 577 | 595 | hasAppliedInitialRowIndex = false |
| 578 | 596 | pendingScrollFileId = nil |
| 579 | 597 | isProgrammaticScrollActive = false |
| 580 | scrollView.setContentOffset(.zero, animated: false) | |
| 598 | scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: false) | |
| 581 | 599 | updateContentMetrics() |
| 582 | 600 | updateViewportFrame() |
| 583 | 601 | lastVisibleFileId = nil |
| ... | ... | @@ -870,8 +888,12 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 870 | 888 | } |
| 871 | 889 | |
| 872 | 890 | 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) | |
| 875 | 897 | let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5 |
| 876 | 898 | isProgrammaticScrollActive = shouldAnimate |
| 877 | 899 | contentView.isVerticalScrollActive = shouldAnimate |
| ... | ... | @@ -889,12 +911,10 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 889 | 911 | return |
| 890 | 912 | } |
| 891 | 913 | |
| 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) | |
| 895 | 916 | hasAppliedInitialRowIndex = true |
| 896 | scrollView.setContentOffset(CGPoint(x: 0, y: targetOffset), animated: false) | |
| 897 | updateViewportFrame() | |
| 917 | setVerticalContentOffset(rowFrame.minY - targetScreenY, animated: false) | |
| 898 | 918 | } |
| 899 | 919 | } |
| 900 | 920 |
apps/mobile/src/Stack.tsx+2-2| ... | ... | @@ -159,7 +159,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { |
| 159 | 159 | }; |
| 160 | 160 | |
| 161 | 161 | // 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. | |
| 163 | 163 | const SOLID_HEADER_OPTIONS: AppScreenOptions = { |
| 164 | 164 | headerBackButtonDisplayMode: "minimal", |
| 165 | 165 | headerBackTitle: "", |
| ... | ... | @@ -750,7 +750,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 750 | 750 | ThreadFile: createNativeStackScreen({ |
| 751 | 751 | screen: ThreadFileScreen, |
| 752 | 752 | linking: `${THREAD_LINKING_PREFIX}/files/:path*`, |
| 753 | options: SOLID_HEADER_OPTIONS, | |
| 753 | options: GLASS_HEADER_OPTIONS, | |
| 754 | 754 | }), |
| 755 | 755 | ThreadMcpApp: createNativeStackScreen({ |
| 756 | 756 | screen: McpAppFullscreenScreen, |
apps/mobile/src/components/ComposerContextSheet.tsx+1| ... | ... | @@ -47,6 +47,7 @@ function ContextField(props: { label: string; value: string | null | undefined; |
| 47 | 47 | style={{ height: Math.min(260, Math.max(100, props.value.split("\n").length * 22 + 36)) }} |
| 48 | 48 | > |
| 49 | 49 | <SourceFileSurface |
| 50 | embedded | |
| 50 | 51 | contents={props.value} |
| 51 | 52 | path={props.label === "HTML" ? "element.html" : "styles.css"} |
| 52 | 53 | /> |
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts+2| ... | ... | @@ -121,6 +121,8 @@ export interface NativeReviewDiffViewProps extends ViewProps { |
| 121 | 121 | readonly styleJson?: string; |
| 122 | 122 | readonly rowHeight: number; |
| 123 | 123 | readonly contentWidth: number; |
| 124 | readonly contentInsetTop?: number; | |
| 125 | readonly contentInsetBottom?: number; | |
| 124 | 126 | readonly initialRowIndex?: number; |
| 125 | 127 | readonly refreshing?: boolean; |
| 126 | 128 | 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"; |
| 8 | 8 | import { AppText as Text } from "../../components/AppText"; |
| 9 | 9 | import { PierreEntryIcon } from "../../components/PierreEntryIcon"; |
| 10 | 10 | import { cn } from "../../lib/cn"; |
| 11 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; | |
| 11 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; | |
| 12 | 12 | import { |
| 13 | 13 | buildFileTree, |
| 14 | 14 | flattenFileTree, |
| ... | ... | @@ -42,7 +42,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 42 | 42 | readonly item: VisibleFileTreeNode; |
| 43 | 43 | readonly selected: boolean; |
| 44 | 44 | readonly expanded: boolean; |
| 45 | readonly loaded: boolean; | |
| 46 | 45 | readonly loading: boolean; |
| 47 | 46 | readonly onPressDirectory: (path: string, expand: boolean) => void; |
| 48 | 47 | readonly onPreviewFile?: (path: string) => void; |
| ... | ... | @@ -98,10 +97,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { |
| 98 | 97 | </Text> |
| 99 | 98 | {node.kind === "directory" && props.expanded && props.loading ? ( |
| 100 | 99 | <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> | |
| 105 | 100 | ) : null} |
| 106 | 101 | </Pressable> |
| 107 | 102 | ); |
| ... | ... | @@ -115,7 +110,6 @@ export function FileTreeBrowser(props: { |
| 115 | 110 | readonly searchQuery: string; |
| 116 | 111 | readonly searchTruncated: boolean; |
| 117 | 112 | readonly selectedPath: string | null; |
| 118 | readonly loadedDirectories: ReadonlySet<string>; | |
| 119 | 113 | readonly loadingDirectories: ReadonlySet<string>; |
| 120 | 114 | readonly onLoadDirectory: (path: string) => void; |
| 121 | 115 | readonly onPreviewFile?: (path: string) => void; |
| ... | ... | @@ -128,14 +122,12 @@ export function FileTreeBrowser(props: { |
| 128 | 122 | readonly selectedPathAtPress: string | null; |
| 129 | 123 | } | null>(null); |
| 130 | 124 | 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; | |
| 134 | 127 | const { |
| 135 | 128 | onLoadDirectory, |
| 136 | 129 | onPreviewFile, |
| 137 | 130 | onSelectFile, |
| 138 | loadedDirectories, | |
| 139 | 131 | loadingDirectories, |
| 140 | 132 | selectedPath: controlledSelectedPath, |
| 141 | 133 | } = props; |
| ... | ... | @@ -226,7 +218,6 @@ export function FileTreeBrowser(props: { |
| 226 | 218 | item={item} |
| 227 | 219 | selected={item.node.kind === "file" && item.node.path === selectedPath} |
| 228 | 220 | expanded={expandedPaths.has(item.node.path)} |
| 229 | loaded={loadedDirectories.has(item.node.path)} | |
| 230 | 221 | loading={loadingDirectories.has(item.node.path)} |
| 231 | 222 | onPressDirectory={toggleDirectory} |
| 232 | 223 | onPreviewFile={onPreviewFile} |
| ... | ... | @@ -237,7 +228,6 @@ export function FileTreeBrowser(props: { |
| 237 | 228 | expandedPaths, |
| 238 | 229 | handleSelectFile, |
| 239 | 230 | onPreviewFile, |
| 240 | loadedDirectories, | |
| 241 | 231 | loadingDirectories, |
| 242 | 232 | selectedPath, |
| 243 | 233 | toggleDirectory, |
| ... | ... | @@ -245,8 +235,8 @@ export function FileTreeBrowser(props: { |
| 245 | 235 | ); |
| 246 | 236 | |
| 247 | 237 | const extraData = useMemo( |
| 248 | () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }), | |
| 249 | [expandedPaths, loadedDirectories, loadingDirectories, selectedPath], | |
| 238 | () => ({ expandedPaths, loadingDirectories, selectedPath }), | |
| 239 | [expandedPaths, loadingDirectories, selectedPath], | |
| 250 | 240 | ); |
| 251 | 241 | |
| 252 | 242 | // UIKit owns the header inset on every supported iOS version. Keep the |
| ... | ... | @@ -259,9 +249,7 @@ export function FileTreeBrowser(props: { |
| 259 | 249 | data={visibleNodes} |
| 260 | 250 | keyExtractor={(item) => item.node.path} |
| 261 | 251 | 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"} | |
| 265 | 253 | keyboardDismissMode="on-drag" |
| 266 | 254 | keyboardShouldPersistTaps="handled" |
| 267 | 255 | estimatedItemSize={42} |
apps/mobile/src/features/files/SourceFileSurface.tsx+23-6| ... | ... | @@ -11,9 +11,10 @@ import { |
| 11 | 11 | useWindowDimensions, |
| 12 | 12 | View, |
| 13 | 13 | } from "react-native"; |
| 14 | ||
| 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 15 | 15 | import { AppText as Text } from "../../components/AppText"; |
| 16 | 16 | import { LoadingStrip } from "../../components/LoadingStrip"; |
| 17 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; | |
| 17 | 18 | import { |
| 18 | 19 | type NativeReviewDiffViewProps, |
| 19 | 20 | resolveNativeReviewDiffView, |
| ... | ... | @@ -39,7 +40,11 @@ import { sourceHighlightAtom } from "./sourceHighlightingState"; |
| 39 | 40 | interface SourceFileSurfaceProps { |
| 40 | 41 | readonly contents: string; |
| 41 | 42 | readonly path: string; |
| 43 | /** The enclosing sheet or card already reserves its own chrome and safe area. */ | |
| 44 | readonly embedded?: boolean; | |
| 42 | 45 | readonly initialLine?: number | null; |
| 46 | /** Override when a fixed notice already reserves the native header. */ | |
| 47 | readonly headerInsetTop?: number; | |
| 43 | 48 | /** Keep the entire document in one native text-selection scope. */ |
| 44 | 49 | readonly selectable?: boolean; |
| 45 | 50 | /** Enables native pull-to-refresh on the source surface. */ |
| ... | ... | @@ -184,6 +189,9 @@ function NativeSourceFileSurface( |
| 184 | 189 | }, |
| 185 | 190 | ) { |
| 186 | 191 | const { NativeView, onRefresh } = props; |
| 192 | const insets = useSafeAreaInsets(); | |
| 193 | const columnMetrics = useNativeColumnLayoutMetrics(); | |
| 194 | const safeArea = columnMetrics?.safeArea ?? insets; | |
| 187 | 195 | const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); |
| 188 | 196 | const { themeAppearance, themeId } = useAppearancePreferences(); |
| 189 | 197 | const appTheme = useUniwindTheme(); |
| ... | ... | @@ -214,6 +222,8 @@ function NativeSourceFileSurface( |
| 214 | 222 | appearanceScheme={themeAppearance} |
| 215 | 223 | contentResetKey={props.path} |
| 216 | 224 | contentWidth={contentWidth} |
| 225 | contentInsetTop={props.embedded ? 0 : (props.headerInsetTop ?? safeArea.top)} | |
| 226 | contentInsetBottom={props.embedded ? 0 : safeArea.bottom + 8} | |
| 217 | 227 | initialRowIndex={targetIndex ?? -1} |
| 218 | 228 | rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight} |
| 219 | 229 | rowsJson={rowsJson} |
| ... | ... | @@ -233,6 +243,7 @@ function NativeSourceFileSurface( |
| 233 | 243 | } |
| 234 | 244 | |
| 235 | 245 | function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 246 | const usesAutomaticInsets = Platform.OS === "ios" && !props.embedded; | |
| 236 | 247 | const foreground = useUniwindTheme()["--color-foreground"]; |
| 237 | 248 | const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); |
| 238 | 249 | const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); |
| ... | ... | @@ -323,6 +334,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 323 | 334 | const list = ( |
| 324 | 335 | <FlatList |
| 325 | 336 | ref={listRef} |
| 337 | contentInsetAdjustmentBehavior={usesAutomaticInsets ? "automatic" : "never"} | |
| 338 | automaticallyAdjustsScrollIndicatorInsets={usesAutomaticInsets} | |
| 326 | 339 | refreshControl={refreshControl} |
| 327 | 340 | data={lines} |
| 328 | 341 | keyExtractor={(_line, index) => String(index)} |
| ... | ... | @@ -354,6 +367,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { |
| 354 | 367 | <ScrollView |
| 355 | 368 | refreshControl={refreshControl} |
| 356 | 369 | className="flex-1" |
| 370 | contentInsetAdjustmentBehavior={usesAutomaticInsets ? "automatic" : "never"} | |
| 371 | automaticallyAdjustsScrollIndicatorInsets={usesAutomaticInsets} | |
| 357 | 372 | contentContainerStyle={{ |
| 358 | 373 | paddingBottom: codeSurface.rowHeight, |
| 359 | 374 | paddingHorizontal: 12, |
| ... | ... | @@ -393,9 +408,11 @@ export function SourceFileSurface(props: SourceFileSurfaceProps) { |
| 393 | 408 | // The native canvas draws source lines without text selection or wrapping. Attachments |
| 394 | 409 | // need one selectable text view in either wrap mode; workspace line navigation can still |
| 395 | 410 | // 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; | |
| 401 | 418 | } |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+7-2| ... | ... | @@ -48,6 +48,7 @@ import { FileTreeBrowser } from "./FileTreeBrowser"; |
| 48 | 48 | import { useFileTreeEntries } from "./useFileTreeEntries"; |
| 49 | 49 | import { preloadWorkspaceFileContents } from "./preload-workspace-file"; |
| 50 | 50 | import { SourceFileSurface } from "./SourceFileSurface"; |
| 51 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; | |
| 51 | 52 | import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; |
| 52 | 53 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 53 | 54 | import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; |
| ... | ... | @@ -229,6 +230,10 @@ function FileContent(props: { |
| 229 | 230 | }) { |
| 230 | 231 | // Reopening a mutable host file must not reuse a poster from an earlier visit. |
| 231 | 232 | 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; | |
| 232 | 237 | const isMarkdown = isMarkdownPreviewFile(props.relativePath); |
| 233 | 238 | const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); |
| 234 | 239 | const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); |
| ... | ... | @@ -300,7 +305,7 @@ function FileContent(props: { |
| 300 | 305 | } |
| 301 | 306 | |
| 302 | 307 | return ( |
| 303 | <View className="flex-1 bg-sheet"> | |
| 308 | <View className="flex-1 bg-sheet" style={{ paddingTop: reservedHeaderInset }}> | |
| 304 | 309 | {props.truncated ? ( |
| 305 | 310 | <FilePreviewNotice title="Partial file"> |
| 306 | 311 | Preview limited to the first 1 MB of a truncated file. |
| ... | ... | @@ -320,6 +325,7 @@ function FileContent(props: { |
| 320 | 325 | contents={props.fileContents} |
| 321 | 326 | path={props.relativePath} |
| 322 | 327 | initialLine={props.initialLine} |
| 328 | headerInsetTop={props.truncated ? 0 : undefined} | |
| 323 | 329 | onRefresh={props.onRefresh} |
| 324 | 330 | /> |
| 325 | 331 | )} |
| ... | ... | @@ -561,7 +567,6 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { |
| 561 | 567 | <FileTreeBrowser |
| 562 | 568 | key={JSON.stringify([environmentId, cwd])} |
| 563 | 569 | entries={entriesQuery.entries} |
| 564 | loadedDirectories={entriesQuery.loadedDirectories} | |
| 565 | 570 | loadingDirectories={entriesQuery.loadingDirectories} |
| 566 | 571 | onLoadDirectory={entriesQuery.loadDirectory} |
| 567 | 572 | error={entriesQuery.error} |
apps/mobile/src/features/files/thread-file-navigator-pane.tsx-1| ... | ... | @@ -87,7 +87,6 @@ export function ThreadFileNavigatorPane(props: { |
| 87 | 87 | <FileTreeBrowser |
| 88 | 88 | key={JSON.stringify([props.environmentId, props.cwd])} |
| 89 | 89 | entries={entriesQuery.entries} |
| 90 | loadedDirectories={entriesQuery.loadedDirectories} | |
| 91 | 90 | loadingDirectories={entriesQuery.loadingDirectories} |
| 92 | 91 | onLoadDirectory={entriesQuery.loadDirectory} |
| 93 | 92 | error={ |