diff --git a/apps/mobile/.swiftlint.yml b/apps/mobile/.swiftlint.yml index 0714ce90e63..c56a56251f7 100644 --- a/apps/mobile/.swiftlint.yml +++ b/apps/mobile/.swiftlint.yml @@ -1,6 +1,7 @@ included: - ios/T3Code - modules/t3-composer-editor/ios + - modules/t3-native-controls/ios - modules/t3-terminal/ios - modules/t3-review-diff/ios diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 8cdf6f2e25c..a75f9f06d1b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -64,7 +64,7 @@ const config: ExpoConfig = { runtimeVersion: { policy: process.env.MOBILE_VERSION_POLICY ?? "appVersion", }, - orientation: "portrait", + orientation: "default", icon: "./assets/icon.png", userInterfaceStyle: "automatic", updates: { diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json new file mode 100644 index 00000000000..e47aa8bbfd8 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["T3NativeControlsModule"] + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift b/apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift new file mode 100644 index 00000000000..7b7f9db6707 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3HeaderButtonView.swift @@ -0,0 +1,62 @@ +import ExpoModulesCore +import UIKit + +public final class T3HeaderButtonView: ExpoView { + private static let size: CGFloat = 44 + private static let symbolSize: CGFloat = 18 + + private let button = UIButton(type: .system) + private var systemImage = "circle" + + let onTriggered = EventDispatcher() + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + isAccessibilityElement = false + button.frame = bounds + button.autoresizingMask = [.flexibleWidth, .flexibleHeight] + button.addTarget(self, action: #selector(handlePress), for: .primaryActionTriggered) + addSubview(button) + applyConfiguration() + } + + public override var intrinsicContentSize: CGSize { + CGSize(width: Self.size, height: Self.size) + } + + public func setLabel(_ label: String) { + button.accessibilityLabel = label + } + + public func setSystemImage(_ systemImage: String) { + guard self.systemImage != systemImage else { + return + } + self.systemImage = systemImage + applyConfiguration() + } + + private func applyConfiguration() { + var configuration: UIButton.Configuration + if #available(iOS 26.0, *) { + configuration = .glass() + configuration.cornerStyle = .capsule + } else { + configuration = .plain() + } + + configuration.baseForegroundColor = .label + configuration.contentInsets = .zero + configuration.image = UIImage(systemName: systemImage) + configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration( + pointSize: Self.symbolSize, + weight: .regular + ) + button.configuration = configuration + } + + @objc private func handlePress() { + onTriggered() + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec new file mode 100644 index 00000000000..29735c301cc --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec @@ -0,0 +1,19 @@ +Pod::Spec.new do |s| + s.name = 'T3NativeControls' + s.version = '1.0.0' + s.summary = 'Native UIKit controls for T3 Code mobile.' + s.description = 'UIKit-backed controls that match native iOS navigation chrome.' + s.author = 'T3 Tools' + s.homepage = 'https://t3tools.com' + s.platforms = { + :ios => '18.0', + } + s.source = { :path => '.' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' +end diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift new file mode 100644 index 00000000000..33e1dc9086c --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -0,0 +1,18 @@ +import ExpoModulesCore + +public final class T3NativeControlsModule: Module { + public func definition() -> ModuleDefinition { + Name("T3NativeControls") + + View(T3HeaderButtonView.self) { + Prop("label") { (view: T3HeaderButtonView, label: String) in + view.setLabel(label) + } + Prop("systemImage") { (view: T3HeaderButtonView, systemImage: String) in + view.setSystemImage(systemImage) + } + + Events("onTriggered") + } + } +} diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index 81cdd8417d3..346588b798f 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -5,22 +5,14 @@ public class T3ReviewDiffModule: Module { Name("T3ReviewDiffSurface") View(T3ReviewDiffView.self) { - Prop("rowsJson") { (view: T3ReviewDiffView, rowsJson: String) in - view.setRowsJson(rowsJson) - } - - Prop("tokensJson") { (view: T3ReviewDiffView, tokensJson: String) in - view.setTokensJson(tokensJson) - } - - Prop("tokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in - view.setTokensPatchJson(tokensPatchJson) - } - Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in view.setTokensResetKey(tokensResetKey) } + Prop("contentResetKey") { (view: T3ReviewDiffView, contentResetKey: String) in + view.setContentResetKey(contentResetKey) + } + Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in view.setCollapsedFileIdsJson(collapsedFileIdsJson) } @@ -61,7 +53,37 @@ public class T3ReviewDiffModule: Module { view.setInitialRowIndex(initialRowIndex) } - Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment") + Events( + "onDebug", + "onVisibleFileChange", + "onToggleFile", + "onToggleViewedFile", + "onPressLine", + "onToggleComment" + ) + + AsyncFunction("scrollToFile") { (view: T3ReviewDiffView, fileId: String, animated: Bool) in + view.scrollToFile(fileId, animated: animated) + } + + AsyncFunction("scrollToTop") { (view: T3ReviewDiffView, animated: Bool) in + view.scrollToTop(animated: animated) + } + + // Large, frequently changing JSON values cannot be regular Fabric props. Expo's + // prop adapter compares strings on the main thread before invoking a setter, which + // makes a syntax-token patch capable of blocking a frame by itself. + AsyncFunction("setRowsJson") { (view: T3ReviewDiffView, rowsJson: String) in + view.setRowsJson(rowsJson) + } + + AsyncFunction("setTokensJson") { (view: T3ReviewDiffView, tokensJson: String) in + view.setTokensJson(tokensJson) + } + + AsyncFunction("setTokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in + view.setTokensPatchJson(tokensPatchJson) + } } } } diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index f8e080e7609..9a7a5004daf 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -1,7 +1,7 @@ import ExpoModulesCore import UIKit -private struct ReviewDiffNativeRow: Decodable { +private struct ReviewDiffNativeRow: Decodable, Sendable { let kind: String let id: String let fileId: String? @@ -21,18 +21,18 @@ private struct ReviewDiffNativeRow: Decodable { let commentSectionTitle: String? } -private struct ReviewDiffNativeWordDiffRange: Decodable { +private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable { let start: Int let end: Int } -private struct ReviewDiffNativeToken: Decodable { +private struct ReviewDiffNativeToken: Decodable, Sendable { let content: String let color: String? let fontStyle: Int? } -private struct ReviewDiffNativeTokenPatch: Decodable { +private struct ReviewDiffNativeTokenPatch: Decodable, Sendable { let resetKey: String? let chunkIndex: Int? let tokensByRowId: [String: [ReviewDiffNativeToken]]? @@ -312,6 +312,10 @@ private struct ReviewDiffNativeStyle { } public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { + private let payloadDecodeQueue = DispatchQueue( + label: "com.t3tools.review-diff.payload-decode", + qos: .userInitiated + ) private let scrollView = UIScrollView() private let contentView = ReviewDiffContentView() private var rows: [ReviewDiffNativeRow] = [] @@ -323,10 +327,18 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { private var lastMetricsDebugKey = "" private var lastVisibleRangeDebugKey = "" private var tokensResetKey = "" + private var contentResetKey = "" private var initialRowIndex: Int? private var hasAppliedInitialRowIndex = false + private var lastVisibleFileId: String? + private var pendingScrollFileId: String? + private var pendingScrollAnimated = false + private var isProgrammaticScrollActive = false + private var rowsDecodeGeneration = 0 + private var tokensDecodeGeneration = 0 let onDebug = EventDispatcher() + let onVisibleFileChange = EventDispatcher() let onToggleFile = EventDispatcher() let onToggleViewedFile = EventDispatcher() let onPressLine = EventDispatcher() @@ -379,6 +391,13 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { updateViewportFrame() } + public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + // A direct gesture takes ownership from any interrupted programmatic jump. + // Resume visible-file events immediately so the inspector follows the finger. + isProgrammaticScrollActive = false + contentView.isVerticalScrollActive = true + } + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { guard !decelerate else { return @@ -396,77 +415,120 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } func setRowsJson(_ rowsJson: String) { - guard let data = rowsJson.data(using: .utf8) else { - return - } + rowsDecodeGeneration += 1 + let generation = rowsDecodeGeneration - do { - rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) - contentView.rows = rows - hasAppliedInitialRowIndex = false - emitDebug("rows-decoded", [ - "rows": rows.count, - "firstKind": rows.first?.kind ?? "none", - ]) - updateContentMetrics() - } catch { - rows = [] - contentView.rows = [] - hasAppliedInitialRowIndex = false - updateContentMetrics() - emitDebug("rows-decode-failed", [ - "error": error.localizedDescription, - ]) + payloadDecodeQueue.async { [weak self] in + guard let data = rowsJson.data(using: .utf8) else { + return + } + + do { + let decodedRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) + DispatchQueue.main.async { [weak self] in + guard let self, generation == self.rowsDecodeGeneration else { + return + } + self.rows = decodedRows + self.contentView.rows = decodedRows + self.hasAppliedInitialRowIndex = false + self.lastVisibleFileId = nil + self.emitDebug("rows-decoded", [ + "rows": decodedRows.count, + "firstKind": decodedRows.first?.kind ?? "none", + ]) + self.updateContentMetrics() + self.applyPendingScrollIfNeeded() + } + } catch { + let message = error.localizedDescription + DispatchQueue.main.async { [weak self] in + guard let self, generation == self.rowsDecodeGeneration else { + return + } + self.rows = [] + self.contentView.rows = [] + self.hasAppliedInitialRowIndex = false + self.lastVisibleFileId = nil + self.pendingScrollFileId = nil + self.updateContentMetrics() + self.emitDebug("rows-decode-failed", ["error": message]) + } + } } } func setTokensJson(_ tokensJson: String) { - guard let data = tokensJson.data(using: .utf8) else { - return - } + tokensDecodeGeneration += 1 + let generation = tokensDecodeGeneration - do { - contentView.tokensByRowId = try JSONDecoder().decode( - [String: [ReviewDiffNativeToken]].self, - from: data - ) - } catch { - contentView.tokensByRowId = [:] - emitDebug("tokens-decode-failed", [ - "error": error.localizedDescription, - ]) + payloadDecodeQueue.async { [weak self] in + guard let data = tokensJson.data(using: .utf8) else { + return + } + + do { + let decodedTokens = try JSONDecoder().decode( + [String: [ReviewDiffNativeToken]].self, + from: data + ) + DispatchQueue.main.async { [weak self] in + guard let self, generation == self.tokensDecodeGeneration else { + return + } + self.contentView.tokensByRowId = decodedTokens + } + } catch { + let message = error.localizedDescription + DispatchQueue.main.async { [weak self] in + guard let self, generation == self.tokensDecodeGeneration else { + return + } + self.contentView.tokensByRowId = [:] + self.emitDebug("tokens-decode-failed", ["error": message]) + } + } } } func setTokensPatchJson(_ tokensPatchJson: String) { - guard let data = tokensPatchJson.data(using: .utf8) else { - return - } - - do { - let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data) - if let resetKey = patch.resetKey, resetKey != tokensResetKey { - tokensResetKey = resetKey - contentView.tokensByRowId = [:] - } - - let tokensByRowId = patch.tokensByRowId ?? [:] - if tokensByRowId.isEmpty { + payloadDecodeQueue.async { [weak self] in + guard let data = tokensPatchJson.data(using: .utf8) else { return } - contentView.mergeTokensByRowId(tokensByRowId) - if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) { - emitDebug("tokens-patch-decoded", [ - "chunkIndex": chunkIndex, - "rows": tokensByRowId.count, - "totalRows": contentView.tokensByRowId.count, - ]) + do { + let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data) + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + // A highlighter request from the previous file can finish after the view has + // already reset. Never let that stale patch roll the native token state back. + if let resetKey = patch.resetKey, resetKey != self.tokensResetKey { + return + } + + let tokensByRowId = patch.tokensByRowId ?? [:] + if tokensByRowId.isEmpty { + return + } + + self.contentView.mergeTokensByRowId(tokensByRowId) + if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) { + self.emitDebug("tokens-patch-decoded", [ + "chunkIndex": chunkIndex, + "rows": tokensByRowId.count, + "totalRows": self.contentView.tokensByRowId.count, + ]) + } + } + } catch { + let message = error.localizedDescription + DispatchQueue.main.async { [weak self] in + self?.emitDebug("tokens-patch-decode-failed", ["error": message]) + } } - } catch { - emitDebug("tokens-patch-decode-failed", [ - "error": error.localizedDescription, - ]) } } @@ -482,6 +544,23 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { ]) } + func setContentResetKey(_ contentResetKey: String) { + guard contentResetKey != self.contentResetKey else { + return + } + + self.contentResetKey = contentResetKey + tokensDecodeGeneration += 1 + contentView.tokensByRowId = [:] + hasAppliedInitialRowIndex = false + lastVisibleFileId = nil + pendingScrollFileId = nil + isProgrammaticScrollActive = false + scrollView.setContentOffset(.zero, animated: false) + updateViewportFrame() + applyInitialRowIndexIfNeeded() + } + func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) { let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson) let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds) @@ -573,6 +652,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { contentView.invalidateVisibleViewport() contentView.setNeedsDisplay() applyInitialRowIndexIfNeeded() + emitVisibleFileIfNeeded() let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))" if debugKey != lastMetricsDebugKey { @@ -596,11 +676,41 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } private func finishVerticalScroll() { + isProgrammaticScrollActive = false contentView.isVerticalScrollActive = false updateViewportFrame() emitVisibleRange(reason: "scroll-end") } + private func emitVisibleFileIfNeeded() { + // Keep the explicit destination selected while UIKit animates through the files + // between the old and new offsets. Emitting every intermediate header forces a + // React render per crossing and makes the navigator visibly flash. + guard !isProgrammaticScrollActive else { + return + } + + // The top of the combined diff is the explicit "All files" destination. + // Treat it as a first-class selection instead of immediately resolving the + // first file header and undoing the navigator's optimistic selection. + if scrollView.contentOffset.y <= 0.5 { + guard lastVisibleFileId != nil else { + return + } + lastVisibleFileId = nil + onVisibleFileChange(["fileId": NSNull()]) + return + } + + guard let fileId = contentView.visibleFileId(atVerticalOffset: scrollView.contentOffset.y), + fileId != lastVisibleFileId else { + return + } + + lastVisibleFileId = fileId + onVisibleFileChange(["fileId": fileId]) + } + private func emitVisibleRange(reason: String) { guard let range = contentView.currentVisibleRowRange() else { return @@ -670,6 +780,18 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { applyInitialRowIndexIfNeeded() } + func scrollToFile(_ fileId: String, animated: Bool) { + pendingScrollFileId = fileId + pendingScrollAnimated = animated + applyPendingScrollIfNeeded() + } + + func scrollToTop(animated: Bool) { + pendingScrollFileId = nil + pendingScrollAnimated = false + setVerticalContentOffset(0, animated: animated) + } + private func applyStyle() { contentView.style = ReviewDiffNativeStyle .resolve(stylePayload) @@ -686,6 +808,38 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { ) contentView.verticalOffset = scrollView.contentOffset.y contentView.invalidateVisibleViewport() + emitVisibleFileIfNeeded() + } + + private func applyPendingScrollIfNeeded() { + guard let fileId = pendingScrollFileId, + bounds.height > 0 else { + return + } + + guard let headerOffset = contentView.fileHeaderOffset(forFileId: fileId) else { + if !rows.isEmpty { + pendingScrollFileId = nil + } + return + } + + let animated = pendingScrollAnimated + pendingScrollFileId = nil + pendingScrollAnimated = false + setVerticalContentOffset(headerOffset, animated: animated) + } + + 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 shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5 + isProgrammaticScrollActive = shouldAnimate + contentView.isVerticalScrollActive = shouldAnimate + scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: shouldAnimate) + if !shouldAnimate { + updateViewportFrame() + } } private func applyInitialRowIndexIfNeeded() { @@ -1255,6 +1409,32 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return rowOffsets[rowIndex] } + func visibleFileId(atVerticalOffset verticalOffset: CGFloat) -> String? { + guard let firstHeaderRowIndex = fileHeaderRowIndices.first else { + return nil + } + + var lowerBound = 0 + var upperBound = fileHeaderRowIndices.count + while lowerBound < upperBound { + let midpoint = (lowerBound + upperBound) / 2 + let rowIndex = fileHeaderRowIndices[midpoint] + if rowOffsets[rowIndex] <= verticalOffset + 0.5 { + lowerBound = midpoint + 1 + } else { + upperBound = midpoint + } + } + + let rowIndex = lowerBound > 0 + ? fileHeaderRowIndices[lowerBound - 1] + : firstHeaderRowIndex + guard rows.indices.contains(rowIndex) else { + return nil + } + return resolvedFileId(for: rows[rowIndex]) + } + private func fileHeaderRowIndex(forFileId fileId: String) -> Int? { fileHeaderRowIndices.first { rowIndex in rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 968be6c14a8..43b490edcbd 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -8,7 +8,7 @@ import { import { usePathname } from "expo-router"; import Stack from "expo-router/stack"; import { useCallback } from "react"; -import { StatusBar, useColorScheme } from "react-native"; +import { StatusBar, useColorScheme, useWindowDimensions } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; @@ -26,6 +26,11 @@ import { useClerkSettingsSheetDetent, } from "../features/cloud/ClerkSettingsSheetDetent"; import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; +import { + AdaptiveWorkspaceLayout, + useAdaptiveWorkspaceLayout, +} from "../features/layout/AdaptiveWorkspaceLayout"; +import { deriveStableFormSheetDetent } from "../lib/layout"; import { useThemeColor } from "../lib/useThemeColor"; function AppNavigator() { @@ -42,13 +47,35 @@ function AppNavigator() { function AppNavigatorContent() { const { state } = useWorkspaceState(); - const { collapse, isExpanded } = useClerkSettingsSheetDetent(); const colorScheme = useColorScheme(); const statusBarBg = useThemeColor("--color-status-bar"); - const sheetStyle = useResolveClassNames("bg-sheet"); useAgentNotificationNavigation(); useThreadOutboxDrain(); + if (state.isLoadingConnections) { + return ; + } + + return ( + <> + + + + + + ); +} + +function WorkspaceNavigator() { + const { collapse, isExpanded } = useClerkSettingsSheetDetent(); + const { layout } = useAdaptiveWorkspaceLayout(); + const { height } = useWindowDimensions(); + const sheetStyle = useResolveClassNames("bg-sheet"); + const handleSettingsTransitionEnd = useCallback( (event: { data: { closing: boolean } }) => { if (event.data.closing) { @@ -58,68 +85,63 @@ function AppNavigatorContent() { [collapse], ); - const newTaskScreenOptions = { + const connectionSheetScreenOptions = { contentStyle: sheetStyle, gestureEnabled: true, headerShown: false, presentation: "formSheet" as const, - sheetAllowedDetents: [0.92], + sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, }; - - const connectionSheetScreenOptions = { + const settingsScreenOptions = layout.usesSplitView + ? { + animation: "fade" as const, + contentStyle: sheetStyle, + gestureEnabled: false, + headerShown: false, + presentation: "card" as const, + } + : { + ...connectionSheetScreenOptions, + sheetAllowedDetents: isExpanded ? [0.92] : [0.7], + }; + const newTaskScreenOptions = { contentStyle: sheetStyle, gestureEnabled: true, headerShown: false, presentation: "formSheet" as const, - sheetAllowedDetents: [0.55, 0.7], - sheetGrabberVisible: true, + sheetAllowedDetents: [layout.usesSplitView ? deriveStableFormSheetDetent(height) : 0.92], + sheetGrabberVisible: !layout.usesSplitView, }; - const settingsSheetScreenOptions = { - ...connectionSheetScreenOptions, - sheetAllowedDetents: isExpanded ? [0.92] : [0.7], - }; - - if (state.isLoadingConnections) { - return ; - } - return ( - <> - + - - - - - - - - + + + + + ); } diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index 7f9962efc98..db1682d2a68 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -10,7 +10,7 @@ import { } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; -import { useRouter } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import { useCallback, useMemo, useState } from "react"; import { useProjects, useThreadShells } from "../state/entities"; @@ -21,6 +21,9 @@ import { HomeScreen } from "../features/home/HomeScreen"; import { HomeHeader } from "../features/home/HomeHeader"; import type { HomeProjectSortOrder } from "../features/home/homeThreadList"; import { useThreadListActions } from "../features/home/useThreadListActions"; +import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout"; +import { WorkspaceEmptyDetail } from "../features/layout/WorkspaceEmptyDetail"; +import { WorkspaceSidebarToolbar } from "../features/layout/workspace-sidebar-toolbar"; interface HomeListOptions { readonly selectedEnvironmentId: EnvironmentId | null; @@ -32,6 +35,7 @@ interface HomeListOptions { /* ─── Route screen ───────────────────────────────────────────────────── */ export default function HomeRouteScreen() { + const { layout } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); const threads = useThreadShells(); const { state: catalogState } = useWorkspaceState(); @@ -80,6 +84,23 @@ export default function HomeRouteScreen() { setListOptions((current) => ({ ...current, projectGroupingMode })); }, []); + if (layout.usesSplitView) { + return ( + <> + + + + + ); + } + return ( <> router.back()} + > + + + ); +} + export default function NewTaskLayout() { + const { layout } = useAdaptiveWorkspaceLayout(); const sheetStyle = useResolveClassNames("bg-sheet"); const sheetBg = useThemeColor("--color-sheet"); const headerTint = useThemeColor("--color-foreground"); @@ -24,6 +46,7 @@ export default function NewTaskLayout() { headerStyle: { backgroundColor: sheetBg }, headerTintColor: headerTint, headerTitleStyle: { fontFamily: "DMSans_700Bold" }, + headerRight: layout.usesSplitView ? NewTaskCloseButton : undefined, }} > diff --git a/apps/mobile/src/app/new/index.tsx b/apps/mobile/src/app/new/index.tsx index 6e2aa64ce11..41a7963807d 100644 --- a/apps/mobile/src/app/new/index.tsx +++ b/apps/mobile/src/app/new/index.tsx @@ -12,6 +12,7 @@ import { useProjects, useThreadShells } from "../../state/entities"; import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; +import { useAdaptiveWorkspaceLayout } from "../../features/layout/AdaptiveWorkspaceLayout"; function deriveProjectEmptyState(catalogState: WorkspaceState): { readonly title: string; @@ -73,6 +74,7 @@ export default function NewTaskRoute() { const threads = useThreadShells(); const { state: catalogState } = useWorkspaceState(); const router = useRouter(); + const { layout } = useAdaptiveWorkspaceLayout(); const insets = useSafeAreaInsets(); const chevronColor = useThemeColor("--color-chevron"); const accentColor = useThemeColor("--color-icon-muted"); @@ -110,6 +112,14 @@ export default function NewTaskRoute() { + {layout.usesSplitView ? ( + router.back()} + separateBackground + /> + ) : null} router.push("/new/add-project")} diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index 41799ae7b8b..76b90108163 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -25,6 +25,8 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions, } from "../../features/cloud/publicConfig"; +import { useAdaptiveWorkspaceLayout } from "../../features/layout/AdaptiveWorkspaceLayout"; +import { WorkspaceSidebarToolbar } from "../../features/layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -34,7 +36,25 @@ type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; export default function SettingsRouteScreen() { - return hasCloudPublicConfig() ? : ; + const router = useRouter(); + const { layout } = useAdaptiveWorkspaceLayout(); + + return ( + <> + + {layout.usesSplitView ? ( + + router.back()} + separateBackground + /> + + ) : null} + {hasCloudPublicConfig() ? : } + + ); } function LocalSettingsRouteScreen() { diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx index 92e90920e2d..6f2fb22833f 100644 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx +++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx @@ -1,8 +1,10 @@ import Stack from "expo-router/stack"; import { StyleSheet } from "react-native"; import { useResolveClassNames } from "uniwind"; +import { useAdaptiveWorkspaceLayout } from "../../../../features/layout/AdaptiveWorkspaceLayout"; export default function ThreadLayout() { + const { fileInspector } = useAdaptiveWorkspaceLayout(); const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet")); const headerBg = { backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor, @@ -58,8 +60,9 @@ export default function ThreadLayout() { { expect(expoMocks.requireNativeView).not.toHaveBeenCalled(); }); - it("returns the native review diff view when the view config is installed", async () => { + it("returns the payload bridge when the native review diff view is installed", async () => { setExpoViewConfigAvailable(); expoMocks.requireNativeView.mockReturnValue(nativeView); const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); - expect(resolveNativeReviewDiffView()).toBe(nativeView); + const resolvedView = resolveNativeReviewDiffView(); + expect(resolvedView).not.toBeNull(); + expect(resolvedView).not.toBe(nativeView); + expect(resolveNativeReviewDiffView()).toBe(resolvedView); expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface"); }); @@ -78,3 +81,20 @@ describe("resolveNativeReviewDiffView", () => { expect(consoleError).toHaveBeenCalledTimes(1); }); }); + +describe("isPendingNativeViewRegistration", () => { + it("recognizes registration races for the installed native view name", async () => { + const { isPendingNativeViewRegistration } = await import("./nativeReviewDiffSurface"); + + expect( + isPendingNativeViewRegistration( + new Error("Unable to find the 'T3ReviewDiffSurface' view for this native tag"), + ), + ).toBe(true); + expect( + isPendingNativeViewRegistration( + new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"), + ), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 7660a047752..a4896dc4011 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -1,4 +1,11 @@ -import type { ComponentType } from "react"; +import { + createElement, + useEffect, + useImperativeHandle, + useRef, + type ComponentType, + type Ref, +} from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { requireNativeView } from "expo"; @@ -103,6 +110,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly tokensJson?: string; readonly tokensPatchJson?: string; readonly tokensResetKey?: string; + readonly contentResetKey?: string; readonly collapsedFileIdsJson?: string; readonly viewedFileIdsJson?: string; readonly selectedRowIdsJson?: string; @@ -113,7 +121,11 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly rowHeight: number; readonly contentWidth: number; readonly initialRowIndex?: number; + readonly nativeViewRef?: Ref; readonly onDebug?: (event: NativeSyntheticEvent>) => void; + readonly onVisibleFileChange?: ( + event: NativeSyntheticEvent<{ readonly fileId?: string | null }>, + ) => void; readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; readonly onPressLine?: ( @@ -129,18 +141,126 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void; } -let cachedNativeReviewDiffView: ComponentType | undefined; +export interface NativeReviewDiffViewHandle { + readonly scrollToFile: (fileId: string, animated?: boolean) => Promise; + readonly scrollToTop: (animated?: boolean) => Promise; +} + +interface NativeReviewDiffViewRef { + readonly setRowsJson: (rowsJson: string) => Promise; + readonly setTokensJson: (tokensJson: string) => Promise; + readonly setTokensPatchJson: (tokensPatchJson: string) => Promise; + readonly scrollToFile: (fileId: string, animated: boolean) => Promise; + readonly scrollToTop: (animated: boolean) => Promise; +} + +type NativeReviewDiffRawViewProps = Omit< + NativeReviewDiffViewProps, + "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson" +> & { + readonly ref?: Ref; +}; + +let cachedNativeReviewDiffRawView: ComponentType | undefined; let nativeReviewDiffViewResolutionFailed = false; +type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson"; + +export function isPendingNativeViewRegistration(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) + ); +} + +function useNativeReviewDiffPayload( + nativeRef: React.RefObject, + method: NativeReviewDiffPayloadMethod, + payload: string | undefined, +) { + useEffect(() => { + if (payload === undefined) { + return; + } + + let cancelled = false; + let frame: number | null = null; + let attempts = 0; + + const dispatch = () => { + if (cancelled) { + return; + } + + const view = nativeRef.current; + const command = view?.[method]; + if (!view || !command) { + if (attempts < 4) { + attempts += 1; + frame = requestAnimationFrame(dispatch); + } + return; + } + + void command.call(view, payload).catch((error: unknown) => { + if (!cancelled && attempts < 4 && isPendingNativeViewRegistration(error)) { + attempts += 1; + frame = requestAnimationFrame(dispatch); + return; + } + console.error(`[native-review-diff] ${method} failed`, error); + }); + }; + + // Fabric attaches the React ref before Expo registers the native tag used by + // view functions. Starting on the next frame avoids racing that registration. + frame = requestAnimationFrame(dispatch); + + return () => { + cancelled = true; + if (frame !== null) { + cancelAnimationFrame(frame); + } + }; + }, [method, nativeRef, payload]); +} + function getExpoViewConfig(moduleName: string) { return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( moduleName, ); } +function NativeReviewDiffView(props: NativeReviewDiffViewProps) { + const { nativeViewRef, rowsJson, tokensJson, tokensPatchJson, ...nativeProps } = props; + const nativeRef = useRef(null); + useNativeReviewDiffPayload(nativeRef, "setRowsJson", rowsJson); + useNativeReviewDiffPayload(nativeRef, "setTokensJson", tokensJson); + useNativeReviewDiffPayload(nativeRef, "setTokensPatchJson", tokensPatchJson); + useImperativeHandle( + nativeViewRef, + () => ({ + scrollToFile: async (fileId, animated = true) => { + await nativeRef.current?.scrollToFile(fileId, animated); + }, + scrollToTop: async (animated = true) => { + await nativeRef.current?.scrollToTop(animated); + }, + }), + [], + ); + + const RawNativeView = cachedNativeReviewDiffRawView; + if (!RawNativeView) { + return null; + } + + return createElement(RawNativeView, { ...nativeProps, ref: nativeRef }); +} + export function resolveNativeReviewDiffView(): ComponentType | null { - if (cachedNativeReviewDiffView) { - return cachedNativeReviewDiffView; + if (cachedNativeReviewDiffRawView) { + return NativeReviewDiffView; } if (nativeReviewDiffViewResolutionFailed) { @@ -152,7 +272,7 @@ export function resolveNativeReviewDiffView(): ComponentType( + cachedNativeReviewDiffRawView = requireNativeView( NATIVE_REVIEW_DIFF_MODULE_NAME, ); } catch (cause) { @@ -166,5 +286,5 @@ export function resolveNativeReviewDiffView(): ComponentType, ReadonlyArray>(); +const FILE_TREE_INITIAL_RENDER_COUNT = 20; +const FILE_TREE_RENDER_BATCH_SIZE = 12; +const OPTIMISTIC_SELECTION_TIMEOUT_MS = 1_000; + +function cachedFileTree(entries: ReadonlyArray): ReadonlyArray { + const cached = fileTreeCache.get(entries); + if (cached !== undefined) { + return cached; + } + const tree = buildFileTree(entries); + fileTreeCache.set(entries, tree); + return tree; +} + function ancestorPaths(path: string): ReadonlyArray { const parts = path.split("/").filter(Boolean); const ancestors: string[] = []; @@ -25,19 +41,24 @@ function ancestorPaths(path: string): ReadonlyArray { const FileTreeRow = memo(function FileTreeRow(props: { readonly item: VisibleFileTreeNode; - readonly selectedPath: string | null; + readonly selected: boolean; readonly expanded: boolean; readonly iconColor: string; readonly onPressDirectory: (path: string) => void; + readonly onPreviewFile?: (path: string) => void; readonly onPressFile: (path: string) => void; }) { const { node, depth } = props.item; - const selected = node.kind === "file" && node.path === props.selectedPath; return ( { + if (node.kind === "file") { + props.onPreviewFile?.(node.path); + } + }} onPress={() => { if (node.kind === "directory") { props.onPressDirectory(node.path); @@ -47,7 +68,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { }} className={cn( "mx-2 min-h-[42px] flex-row items-center gap-2 rounded-[12px] px-2 active:bg-subtle", - selected && "bg-subtle-strong", + props.selected && "bg-subtle-strong", )} style={{ paddingLeft: 8 + depth * 18 }} > @@ -65,7 +86,9 @@ const FileTreeRow = memo(function FileTreeRow(props: { @@ -86,13 +109,26 @@ export function FileTreeBrowser(props: { readonly isPending: boolean; readonly searchQuery: string; readonly selectedPath: string | null; + readonly onPreviewFile?: (path: string) => void; readonly onRefresh: () => void; readonly onSelectFile: (path: string) => void; }) { const [expandedPaths, setExpandedPaths] = useState>(() => new Set()); + const [pendingSelection, setPendingSelection] = useState<{ + readonly path: string; + readonly selectedPathAtPress: string | null; + } | null>(null); const iconColor = String(useThemeColor("--color-icon-muted")); + const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; + const controlledSelectedPathRef = useRef(controlledSelectedPath); + const pendingSelectionTimeoutRef = useRef | null>(null); + controlledSelectedPathRef.current = controlledSelectedPath; - const tree = useMemo(() => buildFileTree(props.entries), [props.entries]); + const selectedPath = + pendingSelection?.selectedPathAtPress === controlledSelectedPath + ? pendingSelection.path + : controlledSelectedPath; + const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]); const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); const visibleNodes = useMemo( () => @@ -114,17 +150,30 @@ export function FileTreeBrowser(props: { }, [defaultExpanded]); useEffect(() => { - if (!props.selectedPath) { + if (!controlledSelectedPath) { return; } setExpandedPaths((current) => { + const ancestors = ancestorPaths(controlledSelectedPath); + if (ancestors.every((ancestor) => current.has(ancestor))) { + return current; + } const next = new Set(current); - for (const ancestor of ancestorPaths(props.selectedPath ?? "")) { + for (const ancestor of ancestors) { next.add(ancestor); } return next; }); - }, [props.selectedPath]); + }, [controlledSelectedPath]); + + useEffect( + () => () => { + if (pendingSelectionTimeoutRef.current !== null) { + clearTimeout(pendingSelectionTimeoutRef.current); + } + }, + [], + ); const toggleDirectory = useCallback((path: string) => { setExpandedPaths((current) => { @@ -137,6 +186,37 @@ export function FileTreeBrowser(props: { return next; }); }, []); + const handleSelectFile = useCallback( + (path: string) => { + if (pendingSelectionTimeoutRef.current !== null) { + clearTimeout(pendingSelectionTimeoutRef.current); + } + setPendingSelection({ + path, + selectedPathAtPress: controlledSelectedPathRef.current, + }); + pendingSelectionTimeoutRef.current = setTimeout(() => { + pendingSelectionTimeoutRef.current = null; + setPendingSelection((current) => (current?.path === path ? null : current)); + }, OPTIMISTIC_SELECTION_TIMEOUT_MS); + onSelectFile(path); + }, + [onSelectFile], + ); + const renderItem = useCallback( + ({ item }: { readonly item: VisibleFileTreeNode }) => ( + + ), + [expandedPaths, handleSelectFile, iconColor, onPreviewFile, selectedPath, toggleDirectory], + ); return ( @@ -152,20 +232,15 @@ export function FileTreeBrowser(props: { contentInsetAdjustmentBehavior="automatic" keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" + initialNumToRender={FILE_TREE_INITIAL_RENDER_COUNT} + maxToRenderPerBatch={FILE_TREE_RENDER_BATCH_SIZE} + updateCellsBatchingPeriod={16} + windowSize={5} contentContainerStyle={{ paddingVertical: 8 }} refreshControl={ } - renderItem={({ item }) => ( - - )} + renderItem={renderItem} ListEmptyComponent={ {props.isPending ? ( diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index b96d6515951..d1644ae2a81 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -20,13 +20,13 @@ import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; import { cn } from "../../lib/cn"; import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import { - buildNativeSourceRows, buildNativeSourceTokens, NATIVE_SOURCE_CONTENT_WIDTH, NATIVE_SOURCE_ROW_HEIGHT, NATIVE_SOURCE_STYLE, nativeSourceRowId, } from "./nativeSourceFileAdapter"; +import { prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; const SOURCE_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; @@ -41,10 +41,6 @@ interface SourceFileSurfaceProps { type SourceHighlightStatus = "highlighting" | "ready" | "error"; -function splitSourceLines(contents: string): ReadonlyArray { - return contents.replace(/\r\n?/g, "\n").split("\n"); -} - const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { readonly index: number; readonly line: string; @@ -119,11 +115,8 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { function useSourceFileModel(props: SourceFileSurfaceProps) { const colorScheme = useColorScheme(); const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; - const normalizedContents = useMemo( - () => props.contents.replace(/\r\n?/g, "\n"), - [props.contents], - ); - const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]); + const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]); + const { contents: normalizedContents, lines, rowsJson } = document; const targetIndex = props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1)) @@ -140,7 +133,7 @@ function useSourceFileModel(props: SourceFileSurfaceProps) { ? "ready" : "highlighting"; - return { lines, status, targetIndex, theme, tokens }; + return { lines, rowsJson, status, targetIndex, theme, tokens }; } function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { @@ -163,8 +156,7 @@ function NativeSourceFileSurface( }, ) { const { NativeView } = props; - const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props); - const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]); + const { rowsJson, status, targetIndex, theme, tokens } = useSourceFileModel(props); const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); const selectedRowIdsJson = useMemo( () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), @@ -176,11 +168,11 @@ function NativeSourceFileSurface( { + if (router.canGoBack()) { + router.back(); + return; + } + if (environmentId !== null && threadId !== null) { + router.replace(buildThreadRoutePath({ environmentId, threadId })); + } + }, [environmentId, router, threadId]); const handleSelectFile = useCallback( (path: string) => { if (environmentId === null || threadId === null) { return; } - router.push(buildThreadFilesNavigation({ environmentId, threadId }, path)); + const destination = buildThreadFilesNavigation({ environmentId, threadId }, path); + const navigationAction = resolveFileSelectionNavigationAction({ + hasPersistentFileInspector: fileInspector.supported, + }); + if (navigationAction === "replace") { + router.replace(destination); + return; + } + router.push(destination); + }, + [environmentId, fileInspector.supported, router, threadId], + ); + const renderInspector = useCallback( + () => + environmentId !== null && cwd !== null ? ( + + ) : null, + [cwd, environmentId, handleSelectFile, headerHeight, projectName], + ); + const handlePreviewFile = useCallback( + (relativePath: string) => { + if (environmentId === null || cwd === null) { + return; + } + preloadWorkspaceFileContents({ + cwd, + environmentId, + relativePath, + theme: highlightTheme, + }); }, - [environmentId, router, threadId], + [cwd, environmentId, highlightTheme], + ); + const renderHeaderTitle = useCallback( + () => , + [projectName], ); if (selectedThread === null || environmentId === null || threadId === null) { @@ -474,6 +546,15 @@ export function ThreadFilesTreeScreen() { return ; } + if (fileInspector.supported) { + return ( + + ); + } + return ( , + headerTitle: renderHeaderTitle, headerSearchBarOptions: { allowToolbarIntegration: true, autoCapitalize: "none", @@ -499,6 +580,18 @@ export function ThreadFilesTreeScreen() { }} /> + {layout.usesSplitView ? ( + + ) : null} @@ -523,6 +617,9 @@ export function ThreadFilesTreeScreen() { } export function ThreadFileScreen() { + useAdaptiveWorkspacePaneRole("inspector"); + const router = useRouter(); + const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const params = useLocalSearchParams<{ line?: string | string[]; path?: string | string[]; @@ -568,6 +665,33 @@ export function ThreadFileScreen() { ); const fileData = fileQuery.data as ProjectReadFileResult | null; + const handleSelectFile = useCallback( + (path: string) => { + // We are already on the catch-all file route. Updating its params keeps + // the current native screen mounted while replacing the selected file in + // place, avoiding an RNSScreen snapshot/unmount for every tree click. + router.setParams({ + line: undefined, + path: path.split("/").filter(Boolean), + }); + }, + [router], + ); + const renderInspector = useCallback( + () => + fileInspector.supported && environmentId !== null && cwd !== null ? ( + + ) : undefined, + [cwd, environmentId, fileInspector.supported, handleSelectFile, projectName, relativePath], + ); + if (selectedThread === null || environmentId === null || threadId === null) { return ; } @@ -588,9 +712,37 @@ export function ThreadFileScreen() { return ( - + + + {fileInspector.supported ? ( + { + if (router.canGoBack()) { + router.back(); + return; + } + router.replace(buildThreadRoutePath({ environmentId, threadId })); + }} + /> + ) : null} + + {fileInspector.supported ? ( + + ) : null} { if (resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)) { @@ -601,25 +753,29 @@ export function ThreadFileScreen() { }} /> - { - setModeOverride({ path: relativePath, mode }); - }} - /> - + + { + setModeOverride({ path: relativePath, mode }); + }} + /> + + ); diff --git a/apps/mobile/src/features/files/preload-workspace-file.ts b/apps/mobile/src/features/files/preload-workspace-file.ts new file mode 100644 index 00000000000..b9e21cfd98f --- /dev/null +++ b/apps/mobile/src/features/files/preload-workspace-file.ts @@ -0,0 +1,74 @@ +import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { appAtomRegistry } from "../../state/atom-registry"; +import { projectEnvironment } from "../../state/projects"; +import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath"; +import { prepareSourceFileDocument } from "./source-file-document"; +import { sourceHighlightAtom } from "./sourceHighlightingState"; +import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; + +const inFlightPreloads = new Map>(); +const MAX_HIGHLIGHT_PRELOAD_CHARACTERS = 256 * 1024; + +function preloadKey(input: { + readonly cwd: string; + readonly environmentId: EnvironmentId; + readonly relativePath: string; +}): string { + return JSON.stringify([input.environmentId, input.cwd, input.relativePath]); +} + +export function preloadWorkspaceFileContents(input: { + readonly cwd: string; + readonly environmentId: EnvironmentId; + readonly relativePath: string; + readonly theme: ReviewDiffTheme; +}): void { + if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) { + return; + } + + const key = preloadKey(input); + if (inFlightPreloads.has(key)) { + return; + } + + const preload = executeAtomQuery( + appAtomRegistry, + projectEnvironment.readFile({ + environmentId: input.environmentId, + input: { cwd: input.cwd, relativePath: input.relativePath }, + }), + { + label: "workspace file preload", + reportDefect: false, + reportFailure: false, + }, + ) + .then(async (result) => { + if (result._tag === "Success") { + const document = prepareSourceFileDocument(result.value.contents); + if (document.contents.length <= MAX_HIGHLIGHT_PRELOAD_CHARACTERS) { + await executeAtomQuery( + appAtomRegistry, + sourceHighlightAtom({ + path: input.relativePath, + contents: document.contents, + theme: input.theme, + }), + { + label: "workspace source highlight preload", + reportDefect: false, + reportFailure: false, + }, + ); + } + } + }) + .finally(() => { + inFlightPreloads.delete(key); + }); + + inFlightPreloads.set(key, preload); +} diff --git a/apps/mobile/src/features/files/source-file-document.test.ts b/apps/mobile/src/features/files/source-file-document.test.ts new file mode 100644 index 00000000000..04b3046994a --- /dev/null +++ b/apps/mobile/src/features/files/source-file-document.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { prepareSourceFileDocument } from "./source-file-document"; + +describe("prepareSourceFileDocument", () => { + it("normalizes and serializes source rows once for repeated consumers", () => { + const first = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r"); + const second = prepareSourceFileDocument("const value = 1;\r\n\tvalue;\r"); + const rows = JSON.parse(first.rowsJson) as ReadonlyArray<{ readonly content: string }>; + + expect(first.contents).toBe("const value = 1;\n\tvalue;\n"); + expect(first.lines).toEqual(["const value = 1;", "\tvalue;", ""]); + expect(rows.map((row) => row.content)).toEqual(["const value = 1;", " value;", ""]); + expect(second).toBe(first); + }); +}); diff --git a/apps/mobile/src/features/files/source-file-document.ts b/apps/mobile/src/features/files/source-file-document.ts new file mode 100644 index 00000000000..d78ead3288f --- /dev/null +++ b/apps/mobile/src/features/files/source-file-document.ts @@ -0,0 +1,54 @@ +import { buildNativeSourceRows } from "./nativeSourceFileAdapter"; + +const MAX_CACHED_DOCUMENTS = 8; +const MAX_CACHED_CHARACTERS = 4 * 1024 * 1024; + +export interface SourceFileDocument { + readonly contents: string; + readonly lines: ReadonlyArray; + readonly rowsJson: string; +} + +const documentCache = new Map(); +let cachedCharacterCount = 0; + +function removeOldestCachedDocument(): void { + const oldestKey = documentCache.keys().next().value; + if (typeof oldestKey !== "string") { + return; + } + const document = documentCache.get(oldestKey); + documentCache.delete(oldestKey); + cachedCharacterCount -= (document?.contents.length ?? 0) + (document?.rowsJson.length ?? 0); +} + +export function prepareSourceFileDocument(contents: string): SourceFileDocument { + const cached = documentCache.get(contents); + if (cached !== undefined) { + documentCache.delete(contents); + documentCache.set(contents, cached); + return cached; + } + + const normalizedContents = contents.replace(/\r\n?/g, "\n"); + const lines = normalizedContents.split("\n"); + const document = { + contents: normalizedContents, + lines, + rowsJson: JSON.stringify(buildNativeSourceRows(lines)), + } satisfies SourceFileDocument; + const characterCount = document.contents.length + document.rowsJson.length; + + if (characterCount <= MAX_CACHED_CHARACTERS) { + while ( + documentCache.size >= MAX_CACHED_DOCUMENTS || + cachedCharacterCount + characterCount > MAX_CACHED_CHARACTERS + ) { + removeOldestCachedDocument(); + } + documentCache.set(contents, document); + cachedCharacterCount += characterCount; + } + + return document; +} diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx new file mode 100644 index 00000000000..c11095b7254 --- /dev/null +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -0,0 +1,90 @@ +import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; +import { SymbolView } from "expo-symbols"; +import { useCallback, useState } from "react"; +import { Pressable, useColorScheme, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; +import { FileTreeBrowser } from "./FileTreeBrowser"; +import { preloadWorkspaceFileContents } from "./preload-workspace-file"; + +export function ThreadFileNavigatorPane(props: { + readonly cwd: string; + readonly environmentId: EnvironmentId; + readonly headerInset: number; + readonly projectName: string; + readonly selectedPath: string | null; + readonly onSelectFile: (path: string) => void; +}) { + const [searchQuery, setSearchQuery] = useState(""); + const colorScheme = useColorScheme(); + const highlightTheme = colorScheme === "dark" ? "dark" : "light"; + const iconColor = String(useThemeColor("--color-icon-muted")); + const entriesQuery = useEnvironmentQuery( + projectEnvironment.listEntries({ + environmentId: props.environmentId, + input: { cwd: props.cwd }, + }), + ); + const entriesData = entriesQuery.data as ProjectListEntriesResult | null; + const handlePreviewFile = useCallback( + (relativePath: string) => { + preloadWorkspaceFileContents({ + cwd: props.cwd, + environmentId: props.environmentId, + relativePath, + theme: highlightTheme, + }); + }, + [highlightTheme, props.cwd, props.environmentId], + ); + + return ( + + + + + Files + + {props.projectName} + + + + + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 7ee5660edf1..ed10660b8f0 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -7,13 +7,10 @@ import type { SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; -import * as Haptics from "expo-haptics"; import { SymbolView } from "expo-symbols"; import { useCallback, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; -import ReanimatedSwipeable, { - type SwipeableMethods, -} from "react-native-gesture-handler/ReanimatedSwipeable"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import Animated, { Easing, LinearTransition, @@ -32,11 +29,7 @@ import type { SavedRemoteConnection } from "../../lib/connection"; import { relativeTime } from "../../lib/time"; import { threadStatusTone } from "../threads/threadPresentation"; import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; -import { - THREAD_SWIPE_ACTIONS_WIDTH, - THREAD_SWIPE_SPRING, - ThreadSwipeActions, -} from "./thread-swipe-actions"; +import { ThreadSwipeable } from "./thread-swipe-actions"; /* ─── Types ──────────────────────────────────────────────────────────── */ @@ -219,13 +212,10 @@ function ThreadRow(props: { readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly isLast: boolean; }) { - const swipeableRef = useRef(null); - const fullSwipeArmedRef = useRef(false); const { width: windowWidth } = useWindowDimensions(); const separatorColor = useThemeColor("--color-separator"); const iconSubtleColor = useThemeColor("--color-icon-subtle"); const cardColor = useThemeColor("--color-card"); - const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, (windowWidth - 32) * 0.58); const { bg, fg } = statusColors(props.thread); const tone = threadStatusTone(props.thread); const timestamp = relativeTime( @@ -235,150 +225,107 @@ function ThreadRow(props: { const subtitleParts = [props.environmentLabel, branch].filter((part): part is string => Boolean(part), ); - const handleFullSwipeArmedChange = useCallback((armed: boolean) => { - if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - } - fullSwipeArmedRef.current = armed; - }, []); return ( - { - fullSwipeArmedRef.current = false; - if (swipeableRef.current) { - props.onSwipeableClose(swipeableRef.current); - } - }} - onSwipeableOpenStartDrag={() => { - if (swipeableRef.current) { - props.onSwipeableWillOpen(swipeableRef.current); - } - }} - onSwipeableWillOpen={() => { - const methods = swipeableRef.current; - if (!methods) { - return; - } - - props.onSwipeableWillOpen(methods); - if (fullSwipeArmedRef.current) { - fullSwipeArmedRef.current = false; - methods.close(); - props.onDelete(); - } + ( - - )} - rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} + threadTitle={props.thread.title} > - { - swipeableRef.current?.close(); - props.onPress(); - }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} - > - ( + { + close(); + props.onPress(); }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} > - - - - - - - {props.thread.title} - - - - - {tone.label} - - - - {timestamp} - - + + - {subtitleParts.length > 0 ? ( - - + + - {subtitleParts.join(" · ")} + {props.thread.title} + + + + {tone.label} + + + + {timestamp} + + - ) : null} + + {subtitleParts.length > 0 ? ( + + + + {subtitleParts.join(" · ")} + + + ) : null} + - - - + + )} + ); } diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index dd0e2901bba..e8b88885bbe 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -1,8 +1,11 @@ import { SymbolView } from "expo-symbols"; -import type { ComponentProps } from "react"; -import type { ColorValue } from "react-native"; +import * as Haptics from "expo-haptics"; +import { useCallback, useRef, type ComponentProps, type ReactNode } from "react"; +import type { ColorValue, StyleProp, ViewStyle } from "react-native"; import { Pressable, View } from "react-native"; -import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import ReanimatedSwipeable, { + type SwipeableMethods, +} from "react-native-gesture-handler/ReanimatedSwipeable"; import Animated, { Extrapolation, interpolate, @@ -26,6 +29,95 @@ export const THREAD_SWIPE_SPRING = { stiffness: 330, }; +interface ThreadSwipePrimaryAction { + readonly accessibilityLabel: string; + readonly icon: ComponentProps["name"]; + readonly label: string; + readonly onPress: () => void; +} + +export function ThreadSwipeable(props: { + readonly backgroundColor: ColorValue; + readonly children: (close: () => void) => ReactNode; + readonly containerStyle?: StyleProp; + readonly fullSwipeWidth: number; + readonly onDelete: () => void; + readonly onSwipeableClose?: (methods: SwipeableMethods) => void; + readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; + readonly primaryAction: ThreadSwipePrimaryAction; + readonly threadTitle: string; +}) { + const swipeableRef = useRef(null); + const fullSwipeArmedRef = useRef(false); + const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, props.fullSwipeWidth * 0.58); + const close = useCallback(() => swipeableRef.current?.close(), []); + const handleFullSwipeArmedChange = useCallback((armed: boolean) => { + if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + fullSwipeArmedRef.current = armed; + }, []); + + return ( + { + fullSwipeArmedRef.current = false; + if (swipeableRef.current) { + props.onSwipeableClose?.(swipeableRef.current); + } + }} + onSwipeableOpenStartDrag={() => { + if (swipeableRef.current) { + props.onSwipeableWillOpen?.(swipeableRef.current); + } + }} + onSwipeableWillOpen={() => { + const methods = swipeableRef.current; + if (!methods) { + return; + } + + props.onSwipeableWillOpen?.(methods); + if (fullSwipeArmedRef.current) { + fullSwipeArmedRef.current = false; + methods.close(); + props.onDelete(); + } + }} + overshootFriction={1} + overshootRight + renderRightActions={(_progress, translation, methods) => ( + { + methods.close(); + props.primaryAction.onPress(); + }, + }} + swipeableMethods={methods} + threadTitle={props.threadTitle} + translation={translation} + /> + )} + rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} + > + {props.children(close)} + + ); +} + function SwipeActionButton(props: { readonly accessibilityLabel: string; readonly backgroundColor: string; @@ -179,12 +271,7 @@ export function ThreadSwipeActions(props: { readonly fullSwipeThreshold: number; readonly onDelete: () => void; readonly onFullSwipeArmedChange: (armed: boolean) => void; - readonly primaryAction: { - readonly accessibilityLabel: string; - readonly icon: ComponentProps["name"]; - readonly label: string; - readonly onPress: () => void; - }; + readonly primaryAction: ThreadSwipePrimaryAction; readonly swipeableMethods: SwipeableMethods; readonly threadTitle: string; readonly translation: SharedValue; diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx new file mode 100644 index 00000000000..3b975217837 --- /dev/null +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -0,0 +1,257 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useFocusEffect, useGlobalSearchParams, usePathname, useRouter } from "expo-router"; +import { + createContext, + use, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useWindowDimensions, View } from "react-native"; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; + +import { + deriveFileInspectorPaneLayout, + deriveLayout, + deriveWorkspacePaneLayout, + type FileInspectorPaneLayout, + type Layout, + type WorkspaceAuxiliaryPaneRole, + type WorkspacePaneLayout, +} from "../../lib/layout"; +import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; +import { buildThreadRoutePath } from "../../lib/routes"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; +import { WORKSPACE_PANE_LAYOUT_TRANSITION } from "./workspace-pane-transition"; + +interface AdaptiveWorkspaceContextValue { + readonly layout: Layout; + readonly panes: WorkspacePaneLayout; + readonly fileInspector: FileInspectorPaneLayout; + readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; + readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; + readonly toggleAuxiliaryPane: () => void; + readonly togglePrimarySidebar: () => void; +} + +const compactLayout = deriveLayout({ width: 0, height: 0 }); +const compactPanes = deriveWorkspacePaneLayout({ + layout: compactLayout, + viewportWidth: 0, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, +}); +const compactFileInspector = deriveFileInspectorPaneLayout({ + layout: compactLayout, + viewportWidth: 0, +}); +const AdaptiveWorkspaceContext = createContext({ + layout: compactLayout, + panes: compactPanes, + fileInspector: compactFileInspector, + activateAuxiliaryPaneRole: () => () => undefined, + showAuxiliaryPane: () => undefined, + toggleAuxiliaryPane: () => undefined, + togglePrimarySidebar: () => undefined, +}); + +function firstRouteParam(value: string | string[] | undefined): string | null { + return Array.isArray(value) ? (value[0] ?? null) : (value ?? null); +} + +export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { + return use(AdaptiveWorkspaceContext); +} + +export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { + const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout(); + + useFocusEffect( + useCallback(() => activateAuxiliaryPaneRole(role), [activateAuxiliaryPaneRole, role]), + ); +} + +export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode }) { + const { width, height } = useWindowDimensions(); + const pathname = usePathname(); + const router = useRouter(); + const activeRoleOwner = useRef(null); + const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); + const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true); + const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); + const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = + useState(null); + const sidebarProgress = useSharedValue(1); + const params = useGlobalSearchParams<{ + environmentId?: string | string[]; + threadId?: string | string[]; + }>(); + const layout = useMemo(() => deriveLayout({ width, height }), [height, width]); + const fileInspector = useMemo( + () => deriveFileInspectorPaneLayout({ layout, viewportWidth: width }), + [layout, width], + ); + const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole = + focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary"); + const auxiliaryPanePreferredVisible = + auxiliaryPaneRole === "inspector" + ? fileInspectorPreferredVisible + : supplementaryPanePreferredVisible; + const panes = useMemo( + () => + deriveWorkspacePaneLayout({ + layout, + viewportWidth: width, + primarySidebarPreferredVisible, + auxiliaryPanePreferredVisible, + auxiliaryPaneRole, + }), + [ + auxiliaryPanePreferredVisible, + auxiliaryPaneRole, + layout, + primarySidebarPreferredVisible, + width, + ], + ); + const environmentId = firstRouteParam(params.environmentId); + const threadId = firstRouteParam(params.threadId); + const selectedThreadKey = + environmentId !== null && threadId !== null + ? scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId)) + : null; + const activateAuxiliaryPaneRole = useCallback((role: WorkspaceAuxiliaryPaneRole) => { + const owner = Symbol(role); + activeRoleOwner.current = owner; + setFocusedAuxiliaryPaneRole(role); + + return () => { + if (activeRoleOwner.current !== owner) { + return; + } + activeRoleOwner.current = null; + setFocusedAuxiliaryPaneRole(null); + }; + }, []); + const togglePrimarySidebar = useCallback(() => { + if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { + setFileInspectorPreferredVisible(false); + setPrimarySidebarPreferredVisible(true); + return; + } + setPrimarySidebarPreferredVisible((current) => !current); + }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible]); + const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => { + if (role === "inspector") { + setFileInspectorPreferredVisible(true); + return; + } + setSupplementaryPanePreferredVisible(true); + }, []); + const toggleAuxiliaryPane = useCallback(() => { + if (auxiliaryPaneRole === "inspector") { + setFileInspectorPreferredVisible((current) => !current); + return; + } + setSupplementaryPanePreferredVisible((current) => !current); + }, [auxiliaryPaneRole]); + const contextValue = useMemo( + () => ({ + layout, + panes, + fileInspector, + activateAuxiliaryPaneRole, + showAuxiliaryPane, + toggleAuxiliaryPane, + togglePrimarySidebar, + }), + [ + activateAuxiliaryPaneRole, + fileInspector, + layout, + panes, + showAuxiliaryPane, + toggleAuxiliaryPane, + togglePrimarySidebar, + ], + ); + + useEffect(() => { + sidebarProgress.value = withTiming(panes.primarySidebarVisible ? 1 : 0, { duration: 220 }); + }, [panes.primarySidebarVisible, sidebarProgress]); + const sidebarStyle = useAnimatedStyle(() => ({ + opacity: sidebarProgress.value, + transform: [{ translateX: (sidebarProgress.value - 1) * 24 }], + })); + + const handleSelectThread = useCallback( + (thread: EnvironmentThreadShell) => { + const destination = buildThreadRoutePath(thread); + const navigationAction = resolveThreadSelectionNavigationAction({ + usesSplitView: layout.usesSplitView, + pathname, + }); + if (navigationAction === "set-params") { + // Auxiliary content belongs to the current thread. Close it before + // reusing the current native detail screen for a peer thread selection. + setFileInspectorPreferredVisible(false); + router.setParams({ + environmentId: String(thread.environmentId), + threadId: String(thread.id), + }); + return; + } + if (navigationAction === "replace") { + setFileInspectorPreferredVisible(false); + router.replace(destination); + return; + } + router.push(destination); + }, + [layout.usesSplitView, pathname, router], + ); + + return ( + + + {layout.usesSplitView && layout.listPaneWidth !== null ? ( + + router.push("/settings")} + onSelectThread={handleSelectThread} + onStartNewTask={() => router.push("/new")} + /> + + ) : null} + + {props.children} + + + + ); +} diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx new file mode 100644 index 00000000000..9d052a89a20 --- /dev/null +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -0,0 +1,21 @@ +import { SymbolView } from "expo-symbols"; +import { View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { useThemeColor } from "../../lib/useThemeColor"; + +export function WorkspaceEmptyDetail() { + const iconColor = useThemeColor("--color-icon-subtle"); + + return ( + + + + Select a thread + + Choose a thread from the sidebar or start a new task. + + + + ); +} diff --git a/apps/mobile/src/features/layout/adaptive-inspector-layout.tsx b/apps/mobile/src/features/layout/adaptive-inspector-layout.tsx new file mode 100644 index 00000000000..0920f409ebc --- /dev/null +++ b/apps/mobile/src/features/layout/adaptive-inspector-layout.tsx @@ -0,0 +1,72 @@ +import { useEffect, type ReactNode } from "react"; +import { View } from "react-native"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; +import { WORKSPACE_PANE_LAYOUT_TRANSITION } from "./workspace-pane-transition"; + +export function AdaptiveInspectorLayout(props: { + readonly children: ReactNode; + readonly renderInspector?: () => ReactNode; +}) { + const { panes } = useAdaptiveWorkspaceLayout(); + const inspectorWidth = panes.auxiliaryPaneWidth; + const inspectorSupported = props.renderInspector !== undefined && inspectorWidth !== null; + const inspectorVisible = inspectorSupported && panes.auxiliaryPaneVisible; + + // A file-to-file replace remounts the route. Initialize an already-visible + // inspector at its final position so route replacement never replays an + // entering transition. Only an explicit visibility change animates it. + const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0); + + useEffect(() => { + inspectorProgress.value = withTiming(inspectorVisible ? 1 : 0, { + duration: inspectorVisible ? 220 : 160, + easing: inspectorVisible ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic), + }); + }, [inspectorProgress, inspectorVisible]); + + const inspectorStyle = useAnimatedStyle( + () => ({ + opacity: inspectorProgress.value, + transform: [{ translateX: (1 - inspectorProgress.value) * 24 }], + }), + [], + ); + + return ( + + + {props.children} + + {inspectorSupported ? ( + + {props.renderInspector?.()} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/layout/workspace-pane-transition.ts b/apps/mobile/src/features/layout/workspace-pane-transition.ts new file mode 100644 index 00000000000..d688b6b739b --- /dev/null +++ b/apps/mobile/src/features/layout/workspace-pane-transition.ts @@ -0,0 +1,10 @@ +import { Easing, LinearTransition } from "react-native-reanimated"; + +/** + * Animates between final Yoga layouts on the UI thread. Keeping pane widths + * out of animated styles avoids recalculating the entire workspace on every + * display frame while a sidebar or inspector moves. + */ +export const WORKSPACE_PANE_LAYOUT_TRANSITION = LinearTransition.duration(220).easing( + Easing.out(Easing.cubic), +); diff --git a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx new file mode 100644 index 00000000000..0bc729f6223 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx @@ -0,0 +1,26 @@ +import { Stack } from "expo-router"; +import type { ReactNode } from "react"; + +import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; + +export function WorkspaceSidebarToolbar(props: { readonly children?: ReactNode } = {}) { + const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + + if (!layout.usesSplitView) { + return null; + } + + return ( + + {props.children} + + + ); +} diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 92203c0ed4e..82c7b5b8796 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -1,10 +1,22 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useLocalSearchParams } from "expo-router"; +import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; import Stack from "expo-router/stack"; import { SymbolView } from "expo-symbols"; -import { memo, type ReactElement, useCallback, useMemo } from "react"; +import { + memo, + type Ref, + type ReactElement, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; import { ActivityIndicator, + FlatList, Pressable, ScrollView, type NativeSyntheticEvent, @@ -23,20 +35,30 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; +import { AdaptiveInspectorLayout } from "../layout/adaptive-inspector-layout"; +import { + useAdaptiveWorkspaceLayout, + useAdaptiveWorkspacePaneRole, +} from "../layout/AdaptiveWorkspaceLayout"; import { useReviewCacheForThread } from "./reviewState"; -import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; +import { + type NativeReviewDiffViewHandle, + resolveNativeReviewDiffView, +} from "../diffs/nativeReviewDiffSurface"; import { NATIVE_REVIEW_DIFF_CONTENT_WIDTH, NATIVE_REVIEW_DIFF_ROW_HEIGHT, } from "./nativeReviewDiffAdapter"; import { useReviewDiffData } from "./useReviewDiffData"; +import { useReviewDiffPrewarming } from "./useReviewDiffPrewarming"; import { useReviewFileVisibility } from "./reviewFileVisibility"; import { useReviewSections } from "./useReviewSections"; import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; import { resolveReviewAvailability } from "./reviewAvailability"; +import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; +import { buildReviewSectionMenu } from "./review-section-menu"; -const IOS_NAV_BAR_HEIGHT = 44; const REVIEW_HEADER_SPACING = 0; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { @@ -110,8 +132,261 @@ function ReviewSelectionActionBar(props: { ); } +interface ReviewNavigatorFile { + readonly id: string; + readonly path: string; + readonly additions: number; + readonly deletions: number; +} + +const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { + readonly file: ReviewNavigatorFile; + readonly selected: boolean; + readonly onSelectFile: (fileId: string | null) => void; +}) { + const { file, selected, onSelectFile } = props; + const handlePress = useCallback(() => { + onSelectFile(file.id); + }, [file.id, onSelectFile]); + + return ( + + + {file.path} + + + +{file.additions} + -{file.deletions} + + + ); +}); + +interface ReviewFileNavigatorHandle { + readonly setVisibleFile: (fileId: string | null) => void; +} + +interface ReviewFileNavigatorProps { + readonly files: ReadonlyArray; + readonly headerInset: number; + readonly sectionId: string | null; + readonly onSelectFile: (fileId: string | null) => void; + readonly ref?: Ref; +} + +function ReviewFileNavigator({ + files, + headerInset, + sectionId, + onSelectFile, + ref, +}: ReviewFileNavigatorProps) { + const [fileSelection, setFileSelection] = useState<{ + readonly sectionId: string | null; + readonly fileId: string | null; + }>({ sectionId: null, fileId: null }); + const availableFileIds = useMemo(() => files.map((file) => file.id), [files]); + const selectedFileId = resolveSelectedReviewFileId({ + selection: fileSelection, + sectionId, + availableFileIds, + }); + + useImperativeHandle( + ref, + () => ({ + setVisibleFile: (fileId) => { + if (fileId !== null && !availableFileIds.includes(fileId)) { + return; + } + setFileSelection((current) => { + if (current.sectionId === sectionId && current.fileId === fileId) { + return current; + } + return { sectionId, fileId }; + }); + }, + }), + [availableFileIds, sectionId], + ); + + const handleSelectFile = useCallback( + (fileId: string | null) => { + setFileSelection({ sectionId, fileId }); + onSelectFile(fileId); + }, + [onSelectFile, sectionId], + ); + + const renderFile = useCallback( + ({ item }: { readonly item: ReviewNavigatorFile }) => ( + + ), + [handleSelectFile, selectedFileId], + ); + + return ( + + + + Changed files + + {files.length} {files.length === 1 ? "file" : "files"} + + + + file.id} + contentContainerStyle={{ paddingHorizontal: 8, paddingVertical: 8 }} + ListHeaderComponent={ + handleSelectFile(null)} + > + All files + + {files.length} changed {files.length === 1 ? "file" : "files"} + + + } + renderItem={renderFile} + /> + + ); +} + +function ReviewHeaderTitle(props: { + readonly additions: string | null; + readonly deletions: string | null; + readonly foregroundColor: string; + readonly mutedColor: string; + readonly pendingCommentCount: number; + readonly sectionTitle: string; +}) { + return ( + + + Files Changed + + + {props.additions && props.deletions ? ( + <> + + {props.additions} + + + {props.deletions} + + {props.pendingCommentCount > 0 ? ( + + {props.pendingCommentCount} pending + + ) : null} + + ) : ( + + + {props.sectionTitle} + + {props.pendingCommentCount > 0 ? ( + + {props.pendingCommentCount} pending + + ) : null} + + )} + + + ); +} + export function ReviewSheet() { + useAdaptiveWorkspacePaneRole("inspector"); + const { layout, panes, showAuxiliaryPane, toggleAuxiliaryPane, togglePrimarySidebar } = + useAdaptiveWorkspaceLayout(); const insets = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); const colorScheme = useColorScheme(); const headerForeground = String(useThemeColor("--color-foreground")); const headerMuted = String(useThemeColor("--color-foreground-muted")); @@ -126,7 +401,11 @@ export function ReviewSheet() { const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const selectedTheme = colorScheme === "dark" ? "dark" : "light"; - const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; + const topContentInset = headerHeight; + + useEffect(() => { + showAuxiliaryPane("inspector"); + }, [environmentId, showAuxiliaryPane, threadId]); const { error, loadingGitDiffs, @@ -141,6 +420,11 @@ export function ReviewSheet() { threadId, reviewCache, }); + useReviewDiffPrewarming({ + threadKey: reviewCache.threadKey, + sections: reviewSections, + selectedSectionId: selectedSection?.id ?? null, + }); const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = useReviewDiffData({ threadKey: reviewCache.threadKey, @@ -148,6 +432,8 @@ export function ReviewSheet() { draftMessage, }); const NativeReviewDiffView = resolveNativeReviewDiffView()!; + const nativeReviewDiffViewRef = useRef(null); + const reviewFileNavigatorRef = useRef(null); const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : []; const fileVisibility = useReviewFileVisibility({ threadKey: reviewCache.threadKey, @@ -179,6 +465,41 @@ export function ReviewSheet() { canHighlight: parsedDiff.kind === "files", }); + const handleSelectFile = useCallback( + (fileId: string | null) => { + commentSelection.clearSelection(); + if (fileId !== null && collapsedFileIds.includes(fileId)) { + toggleExpandedFile(fileId); + } + const navigation = + fileId === null + ? nativeReviewDiffViewRef.current?.scrollToTop(true) + : nativeReviewDiffViewRef.current?.scrollToFile(fileId, true); + void navigation?.catch((error: unknown) => { + console.error("[review] Failed to navigate to diff file", error); + }); + }, + [collapsedFileIds, commentSelection, toggleExpandedFile], + ); + const handleVisibleFileChange = useCallback( + (event: NativeSyntheticEvent<{ readonly fileId?: string | null }>) => { + reviewFileNavigatorRef.current?.setVisibleFile(event.nativeEvent.fileId ?? null); + }, + [], + ); + const renderInspector = useCallback( + () => ( + + ), + [handleSelectFile, headerHeight, nativeReviewDiffData.files, selectedSection?.id], + ); + const handleNativeToggleFile = useCallback( (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => { const { fileId } = event.nativeEvent; @@ -203,6 +524,7 @@ export function ReviewSheet() { parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; const hasCachedSelectedDiff = selectedSection?.diff != null; const hasAnyCachedDiff = reviewSections.some((section) => section.diff != null); + const sectionMenu = useMemo(() => buildReviewSectionMenu(reviewSections), [reviewSections]); const { showConnectionNotice, showSectionToolbar } = resolveReviewAvailability({ hasEnvironmentPresentation: environment.isReady, isEnvironmentConnected: isEnvironmentReady, @@ -235,6 +557,26 @@ export function ReviewSheet() { return <>{children}; }, [error, parsedDiffNotice]); + const renderHeaderTitle = useCallback( + () => ( + + ), + [ + headerDiffSummary.additions, + headerDiffSummary.deletions, + headerForeground, + headerMuted, + pendingReviewCommentCount, + selectedSection?.title, + ], + ); return ( <> @@ -246,122 +588,98 @@ export function ReviewSheet() { headerStyle: { backgroundColor: "transparent", }, - headerTitle: () => ( - - - Files Changed - - - {headerDiffSummary.additions && headerDiffSummary.deletions ? ( - <> - - {headerDiffSummary.additions} - - - {headerDiffSummary.deletions} - - {pendingReviewCommentCount > 0 ? ( - - {pendingReviewCommentCount} pending - - ) : null} - - ) : ( - - - {selectedSection?.title ?? "Review changes"} - - {pendingReviewCommentCount > 0 ? ( - - {pendingReviewCommentCount} pending - - ) : null} - - )} - - - ), + headerTitle: renderHeaderTitle, }} /> - {showSectionToolbar ? ( + {layout.usesSplitView || showSectionToolbar || panes.supportsAuxiliaryPane ? ( - - {reviewSections.map((section) => ( + {layout.usesSplitView ? ( + + ) : null} + {panes.supportsAuxiliaryPane ? ( + + ) : null} + {showSectionToolbar ? ( + + + { + if (sectionMenu.workingTree) { + selectSection(sectionMenu.workingTree.id); + } + }} + > + Working tree + + { + if (sectionMenu.branchChanges) { + selectSection(sectionMenu.branchChanges.id); + } + }} + > + Branch changes + + { + if (sectionMenu.latestTurn) { + selectSection(sectionMenu.latestTurn.id); + } + }} + > + Latest turn + + {sectionMenu.turns.length > 0 ? ( + + {sectionMenu.turns.map((section) => ( + selectSection(section.id)} + subtitle={section.subtitle ?? undefined} + > + {section.title} + + ))} + + ) : null} + selectSection(section.id)} - subtitle={section.subtitle ?? undefined} + icon="arrow.clockwise" + disabled={ + loadingGitDiffs || + (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true) + } + onPress={() => void refreshSelectedSection()} + subtitle="Reload current diff" > - {section.title} + Refresh - ))} - void refreshSelectedSection()} - subtitle="Reload current diff" - > - Refresh - - + + ) : null} ) : null} @@ -386,35 +704,43 @@ export function ReviewSheet() { className="flex-1" style={{ backgroundColor: nativeBridge.theme.background, - paddingTop: topContentInset + REVIEW_HEADER_SPACING, }} > - {listHeader} - - - + + + {listHeader} + + + + + ) : ( { + it("reuses the row model for equivalent empty comment arrays", () => { + const first = getCachedNativeReviewDiffData(buildInput([])); + const second = getCachedNativeReviewDiffData(buildInput([])); + + expect(second).toBe(first); + }); + + it("reuses equivalent comment contents and invalidates changed comments", () => { + const first = getCachedNativeReviewDiffData(buildInput([makeComment("First")])); + const equivalent = getCachedNativeReviewDiffData(buildInput([makeComment("First")])); + const changed = getCachedNativeReviewDiffData(buildInput([makeComment("Changed")])); + + expect(equivalent).toBe(first); + expect(changed).not.toBe(first); + }); +}); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index f60fdfe70e0..a38e0019369 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -75,6 +75,33 @@ export interface BuildNativeReviewDiffDataInput { readonly comments?: ReadonlyArray; } +interface CachedNativeReviewDiffData { + readonly commentsKey: string; + readonly data: NativeReviewDiffData; +} + +const nativeReviewDiffDataCache = new WeakMap(); + +function buildReviewCommentsCacheKey(comments: ReadonlyArray): string { + if (comments.length === 0) { + return "none"; + } + + return comments + .map((comment) => + [ + comment.id, + comment.sectionId, + comment.filePath, + comment.startIndex, + comment.endIndex, + comment.rangeLabel, + comment.text, + ].join("\u001f"), + ) + .join("\u001e"); +} + export function createNativeReviewDiffTheme( scheme: TerminalAppearanceScheme, ): NativeReviewDiffTheme { @@ -430,3 +457,26 @@ export function buildNativeReviewDiffData( deletions: parsedDiff.deletions, }; } + +/** + * Reuses the expensive flattened native row model across React development + * render probes and unrelated draft updates. Only the latest comment version + * is retained for each parsed diff so editing a comment cannot grow the cache. + */ +export function getCachedNativeReviewDiffData( + input: BuildNativeReviewDiffDataInput, +): NativeReviewDiffData { + const comments = input.comments ?? []; + const commentsKey = buildReviewCommentsCacheKey(comments); + const cached = nativeReviewDiffDataCache.get(input.parsedDiff); + if (cached?.commentsKey === commentsKey) { + return cached.data; + } + + const data = buildNativeReviewDiffData({ + parsedDiff: input.parsedDiff, + comments, + }); + nativeReviewDiffDataCache.set(input.parsedDiff, { commentsKey, data }); + return data; +} diff --git a/apps/mobile/src/features/review/review-section-menu.test.ts b/apps/mobile/src/features/review/review-section-menu.test.ts new file mode 100644 index 00000000000..7eca4ce3b4d --- /dev/null +++ b/apps/mobile/src/features/review/review-section-menu.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { ReviewSectionItem, ReviewSectionKind } from "./reviewModel"; +import { buildReviewSectionMenu } from "./review-section-menu"; + +function section(id: string, kind: ReviewSectionKind): ReviewSectionItem { + return { + id, + kind, + title: id, + subtitle: null, + diff: null, + isLoading: false, + }; +} + +describe("buildReviewSectionMenu", () => { + it("exposes git scopes and the latest turn at the top level", () => { + const turn28 = section("turn:28", "turn"); + const turn27 = section("turn:27", "turn"); + const workingTree = section("git:working-tree", "working-tree"); + const branchChanges = section("git:branch-range", "branch-range"); + + expect(buildReviewSectionMenu([turn28, turn27, workingTree, branchChanges])).toEqual({ + workingTree, + branchChanges, + latestTurn: turn28, + turns: [turn28, turn27], + }); + }); + + it("keeps unavailable scopes empty while data loads", () => { + expect(buildReviewSectionMenu([])).toEqual({ + workingTree: null, + branchChanges: null, + latestTurn: null, + turns: [], + }); + }); +}); diff --git a/apps/mobile/src/features/review/review-section-menu.ts b/apps/mobile/src/features/review/review-section-menu.ts new file mode 100644 index 00000000000..87d10266529 --- /dev/null +++ b/apps/mobile/src/features/review/review-section-menu.ts @@ -0,0 +1,21 @@ +import type { ReviewSectionItem } from "./reviewModel"; + +export interface ReviewSectionMenu { + readonly workingTree: ReviewSectionItem | null; + readonly branchChanges: ReviewSectionItem | null; + readonly latestTurn: ReviewSectionItem | null; + readonly turns: ReadonlyArray; +} + +export function buildReviewSectionMenu( + sections: ReadonlyArray, +): ReviewSectionMenu { + const turns = sections.filter((section) => section.kind === "turn"); + + return { + workingTree: sections.find((section) => section.kind === "working-tree") ?? null, + branchChanges: sections.find((section) => section.kind === "branch-range") ?? null, + latestTurn: turns[0] ?? null, + turns, + }; +} diff --git a/apps/mobile/src/features/review/reviewPaneSelection.test.ts b/apps/mobile/src/features/review/reviewPaneSelection.test.ts new file mode 100644 index 00000000000..5b1574d8f42 --- /dev/null +++ b/apps/mobile/src/features/review/reviewPaneSelection.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; + +describe("resolveSelectedReviewFileId", () => { + it("keeps a visible file selected within the active section", () => { + expect( + resolveSelectedReviewFileId({ + selection: { sectionId: "worktree", fileId: "second" }, + sectionId: "worktree", + availableFileIds: ["first", "second"], + }), + ).toBe("second"); + }); + + it("clears selection when the review section changes", () => { + expect( + resolveSelectedReviewFileId({ + selection: { sectionId: "turn-1", fileId: "first" }, + sectionId: "turn-2", + availableFileIds: ["first"], + }), + ).toBeNull(); + }); + + it("clears a file that no longer exists in the diff", () => { + expect( + resolveSelectedReviewFileId({ + selection: { sectionId: "worktree", fileId: "removed" }, + sectionId: "worktree", + availableFileIds: ["first", "second"], + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/review/reviewPaneSelection.ts b/apps/mobile/src/features/review/reviewPaneSelection.ts new file mode 100644 index 00000000000..6f3b7e41c1f --- /dev/null +++ b/apps/mobile/src/features/review/reviewPaneSelection.ts @@ -0,0 +1,16 @@ +export interface ReviewPaneFileSelection { + readonly sectionId: string | null; + readonly fileId: string | null; +} + +export function resolveSelectedReviewFileId(input: { + readonly selection: ReviewPaneFileSelection; + readonly sectionId: string | null; + readonly availableFileIds: ReadonlyArray; +}): string | null { + if (input.selection.sectionId !== input.sectionId || input.selection.fileId === null) { + return null; + } + + return input.availableFileIds.includes(input.selection.fileId) ? input.selection.fileId : null; +} diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index ee04673dfc0..85aa6b032fa 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -1,11 +1,13 @@ import { useEffect, useMemo } from "react"; import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; -import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter"; +import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; import { markReviewEvent, measureReviewWork } from "./reviewPerf"; import { getCachedReviewParsedDiff } from "./reviewState"; import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel"; +const EMPTY_INLINE_REVIEW_COMMENTS = Object.freeze([]); + function isReviewDiffDebugLoggingEnabled(): boolean { return typeof __DEV__ !== "undefined" ? __DEV__ : false; } @@ -43,6 +45,7 @@ export function useReviewDiffData(input: { readonly draftMessage: string; }) { const { draftMessage, selectedSection, threadKey } = input; + const selectedSectionId = selectedSection?.id ?? null; const parsedDiff = useMemo( () => measureReviewWork("parse-diff", () => @@ -59,17 +62,16 @@ export function useReviewDiffData(input: { () => parseReviewInlineComments(draftMessage), [draftMessage], ); - const selectedSectionInlineComments = useMemo( - () => - selectedSection - ? inlineReviewComments.filter((comment) => comment.sectionId === selectedSection.id) - : [], - [inlineReviewComments, selectedSection], - ); + const selectedSectionInlineComments = useMemo(() => { + if (!selectedSectionId || inlineReviewComments.length === 0) { + return EMPTY_INLINE_REVIEW_COMMENTS; + } + return inlineReviewComments.filter((comment) => comment.sectionId === selectedSectionId); + }, [inlineReviewComments, selectedSectionId]); const nativeReviewDiffData = useMemo( () => measureReviewWork("build-native-diff-data", () => - buildNativeReviewDiffData({ + getCachedNativeReviewDiffData({ parsedDiff, comments: selectedSectionInlineComments, }), diff --git a/apps/mobile/src/features/review/useReviewDiffPrewarming.ts b/apps/mobile/src/features/review/useReviewDiffPrewarming.ts new file mode 100644 index 00000000000..80a2a64dbf6 --- /dev/null +++ b/apps/mobile/src/features/review/useReviewDiffPrewarming.ts @@ -0,0 +1,101 @@ +import { useEffect } from "react"; + +import { getCachedNativeReviewDiffData } from "./nativeReviewDiffAdapter"; +import type { ReviewSectionItem } from "./reviewModel"; +import { getCachedReviewParsedDiff } from "./reviewState"; + +interface IdleDeadlineLike { + readonly didTimeout: boolean; + timeRemaining(): number; +} + +type IdleCallback = (deadline: IdleDeadlineLike) => void; + +function scheduleIdle(callback: IdleCallback): number { + if (typeof globalThis.requestIdleCallback === "function") { + return globalThis.requestIdleCallback(callback, { timeout: 2_000 }); + } + + return setTimeout( + () => callback({ didTimeout: true, timeRemaining: () => 0 }), + 100, + ) as unknown as number; +} + +function cancelIdle(handle: number): void { + if (typeof globalThis.cancelIdleCallback === "function") { + globalThis.cancelIdleCallback(handle); + return; + } + clearTimeout(handle); +} + +export function prewarmReviewDiffSection(input: { + readonly threadKey: string; + readonly section: ReviewSectionItem; +}): void { + const { section, threadKey } = input; + if (section.diff === null) { + return; + } + + const parsedDiff = getCachedReviewParsedDiff({ + threadKey, + sectionId: section.id, + diff: section.diff, + }); + getCachedNativeReviewDiffData({ parsedDiff, comments: [] }); +} + +/** Warms one cached section per idle period, after navigation animations finish. */ +export function useReviewDiffPrewarming(input: { + readonly threadKey: string | null; + readonly sections: ReadonlyArray; + readonly selectedSectionId: string | null; +}): void { + const { sections, selectedSectionId, threadKey } = input; + + useEffect(() => { + if (!threadKey) { + return; + } + + const pendingSections = sections.filter( + (section) => section.id !== selectedSectionId && section.diff !== null, + ); + if (pendingSections.length === 0) { + return; + } + + let cancelled = false; + let idleHandle: number | null = null; + let nextSectionIndex = 0; + + const scheduleNext = () => { + idleHandle = scheduleIdle(() => { + if (cancelled) { + return; + } + + const section = pendingSections[nextSectionIndex]; + if (!section) { + return; + } + nextSectionIndex += 1; + prewarmReviewDiffSection({ threadKey, section }); + + if (nextSectionIndex < pendingSections.length) { + scheduleNext(); + } + }); + }; + + scheduleNext(); + return () => { + cancelled = true; + if (idleHandle !== null) { + cancelIdle(idleHandle); + } + }; + }, [sections, selectedSectionId, threadKey]); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 0050eb923be..81fb8505776 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -75,6 +75,7 @@ export interface ThreadComposerProps { readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; readonly placeholder: string; + readonly contentMaxWidth?: number; readonly bottomInset?: number; readonly connectionState: RemoteClientConnectionState; readonly connectionError: string | null; @@ -630,7 +631,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)", }} > - + {composerTrigger && composerMenuItems.length > 0 ? ( - - {props.activeWorkStartedAt ? ( - - ) : null} - - {props.activePendingApproval || props.activePendingUserInput ? ( - - {props.activePendingApproval ? ( - - ) : null} - {props.activePendingUserInput ? ( - - ) : null} - - ) : null} + + + {props.activeWorkStartedAt ? ( + + ) : null} + + {props.activePendingApproval || props.activePendingUserInput ? ( + + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} + ; @@ -1125,6 +1127,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const listRef = useRef(null); const copyFeedbackTimeoutRef = useRef | null>(null); const scrollFrameRef = useRef(null); + const revealFrameRef = useRef(null); + const revealSettleFrameRef = useRef(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); const suppressAutoFollowRef = useRef(false); @@ -1132,7 +1136,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const isNearEndRef = useRef(true); const initialScrollReadyRef = useRef(false); const lastContentHeightRef = useRef(0); - const { width: viewportWidth } = useWindowDimensions(); + const { width: windowWidth } = useWindowDimensions(); + const [viewportWidth, setViewportWidth] = useState(() => + props.layoutVariant === "split" ? 0 : windowWidth, + ); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1149,14 +1156,25 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { uri: string; headers?: Record; } | null>(null); + const [revealedThreadId, setRevealedThreadId] = useState(null); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; - const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); + const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ + viewportWidth, + maxContentWidth: props.contentMaxWidth ?? null, + minimumPadding: horizontalPadding, + }); + const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2); const userBubbleMaxWidth = contentWidth * 0.85; const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); const topContentInset = props.contentTopInset ?? insets.top + 44; const bottomContentInset = props.contentBottomInset ?? 18; + const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { + const nextWidth = Math.round(event.nativeEvent.layout.width); + setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); + }, []); + const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); const onMarkdownLinkPress = useCallback( @@ -1199,6 +1217,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { markdownStyles, reviewCommentColors, userBubbleColor, + viewportWidth, }), [ copiedRowId, @@ -1208,6 +1227,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { markdownStyles, reviewCommentColors, userBubbleColor, + viewportWidth, ], ); const presentedFeed = useMemo( @@ -1275,7 +1295,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const onListLoad = useCallback(() => { initialScrollReadyRef.current = true; - }, []); + listRef.current?.scrollToEnd({ animated: false }); + + // Bottom-aligned initialScrollIndex uses LegendList's multi-frame + // convergence loop. Very tall Markdown rows can exhaust that loop and + // visibly correct their position for eight frames. Mount offscreen, make + // one measured jump, then reveal after the following layout has settled. + if (revealFrameRef.current !== null) { + cancelAnimationFrame(revealFrameRef.current); + } + if (revealSettleFrameRef.current !== null) { + cancelAnimationFrame(revealSettleFrameRef.current); + } + revealFrameRef.current = requestAnimationFrame(() => { + listRef.current?.scrollToEnd({ animated: false }); + revealFrameRef.current = null; + revealSettleFrameRef.current = requestAnimationFrame(() => { + revealSettleFrameRef.current = null; + setRevealedThreadId(props.threadId); + }); + }); + }, [props.threadId]); + + useEffect(() => { + if (revealFrameRef.current !== null) { + cancelAnimationFrame(revealFrameRef.current); + revealFrameRef.current = null; + } + if (revealSettleFrameRef.current !== null) { + cancelAnimationFrame(revealSettleFrameRef.current); + revealSettleFrameRef.current = null; + } + initialScrollReadyRef.current = false; + isNearEndRef.current = true; + lastContentHeightRef.current = 0; + }, [props.threadId]); useEffect(() => { const previous = previousLatestTurnRef.current; @@ -1311,6 +1365,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (scrollFrameRef.current !== null) { cancelAnimationFrame(scrollFrameRef.current); } + if (revealFrameRef.current !== null) { + cancelAnimationFrame(revealFrameRef.current); + } + if (revealSettleFrameRef.current !== null) { + cancelAnimationFrame(revealSettleFrameRef.current); + } if (foldSettleFrameRef.current !== null) { cancelAnimationFrame(foldSettleFrameRef.current); } @@ -1472,11 +1532,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return ( <> - + diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index d5920a72411..478aae3d515 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -66,6 +66,10 @@ function compactMenuStatus(gitStatus: VcsStatusResult | null): string { } export function ThreadGitControls(props: { + readonly auxiliaryPaneControl?: { + readonly accessibilityLabel: string; + readonly onPress: () => void; + }; readonly currentBranch: string | null; readonly gitStatus: VcsStatusResult | null; readonly gitOperationLabel: string | null; @@ -73,6 +77,8 @@ export function ThreadGitControls(props: { readonly canOpenFiles: boolean; readonly projectScripts: ReadonlyArray; readonly terminalSessions: ReadonlyArray; + readonly onOpenFilesInspector?: () => void; + readonly onOpenGitInspector?: () => void; readonly onOpenTerminal: (terminalId?: string | null) => void; readonly onOpenNewTerminal: () => void; readonly onRunProjectScript: (script: ProjectScript) => Promise; @@ -183,6 +189,14 @@ export function ThreadGitControls(props: { return ( + {props.auxiliaryPaneControl ? ( + + ) : null} {props.projectScripts.length > 0 ? ( props.projectScripts.map((script) => ( @@ -259,19 +273,29 @@ export function ThreadGitControls(props: { router.push(buildThreadFilesNavigation({ environmentId, threadId }))} + onPress={() => { + if (props.onOpenFilesInspector) { + props.onOpenFilesInspector(); + return; + } + router.push(buildThreadFilesNavigation({ environmentId, threadId })); + }} subtitle="Browse this workspace" > Files + onPress={() => { + if (props.onOpenGitInspector) { + props.onOpenGitInspector(); + return; + } router.push({ pathname: "/threads/[environmentId]/[threadId]/git", params: { environmentId, threadId }, - }) - } + }); + }} subtitle="Commit, files, branches" > More diff --git a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx index 9318fb76017..3710bf255ac 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx @@ -8,8 +8,6 @@ import { useWindowDimensions, View, } from "react-native"; -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { @@ -23,22 +21,11 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { StatusPill } from "../../components/StatusPill"; import { useProjects, useThreadShells } from "../../state/entities"; -import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { relativeTime } from "../../lib/time"; import { threadStatusTone } from "./threadPresentation"; -import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -const threadActivityOrder = Order.mapInput( - Order.Struct({ - activityAt: Order.flip(Order.Number), - title: Order.String, - }), - (thread: EnvironmentThreadShell) => ({ - activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), - title: thread.title, - }), -); +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { buildThreadNavigationGroups } from "./thread-navigation-groups"; export function ThreadNavigationDrawer(props: { readonly visible: boolean; @@ -192,24 +179,9 @@ function ThreadNavigationDrawerContent(props: { }) { const projects = useProjects(); const threads = useThreadShells(); - const repositoryGroups = useMemo( - () => groupProjectsByRepository({ projects, threads }), - [projects, threads], - ); const groupedThreads = useMemo( - () => - repositoryGroups.map((group) => { - const threads: EnvironmentThreadShell[] = []; - for (const projectGroup of group.projects) { - threads.push(...projectGroup.threads); - } - return { - key: group.key, - title: group.projects[0]?.project.title ?? group.title, - threads: Arr.sort(threads, threadActivityOrder), - }; - }), - [repositoryGroups], + () => buildThreadNavigationGroups({ projects, threads }), + [projects, threads], ); return ( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx new file mode 100644 index 00000000000..db84429f87e --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -0,0 +1,303 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { SymbolView } from "expo-symbols"; +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import type { ColorValue } from "react-native"; +import { Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { StatusPill } from "../../components/StatusPill"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { relativeTime } from "../../lib/time"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useProjects, useThreadShells } from "../../state/entities"; +import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { useThreadListActions } from "../home/useThreadListActions"; +import { buildThreadNavigationGroups } from "./thread-navigation-groups"; +import { SidebarHeaderActions } from "./sidebar-header-actions"; +import { threadStatusTone } from "./threadPresentation"; + +const ThreadNavigationRow = memo(function ThreadNavigationRow(props: { + readonly backgroundColor: ColorValue; + readonly fullSwipeWidth: number; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly pressedBackgroundColor: ColorValue; + readonly selected: boolean; + readonly selectedBackgroundColor: ColorValue; + readonly thread: EnvironmentThreadShell; +}) { + const { + backgroundColor, + fullSwipeWidth, + onArchiveThread, + onDeleteThread, + onSelectThread, + onSwipeableClose, + onSwipeableWillOpen, + pressedBackgroundColor, + selected, + selectedBackgroundColor, + thread, + } = props; + const handleArchive = useCallback(() => { + onArchiveThread(thread); + }, [onArchiveThread, thread]); + const handleDelete = useCallback(() => { + onDeleteThread(thread); + }, [onDeleteThread, thread]); + const primaryAction = useMemo( + () => ({ + accessibilityLabel: `Archive ${thread.title}`, + icon: "archivebox" as const, + label: "Archive", + onPress: handleArchive, + }), + [handleArchive, thread.title], + ); + + return ( + + {(close) => ( + { + close(); + onSelectThread(thread); + }} + style={({ pressed }) => [ + styles.threadRow, + { + backgroundColor: selected + ? selectedBackgroundColor + : pressed + ? pressedBackgroundColor + : backgroundColor, + }, + ]} + > + + + {thread.title} + + + {relativeTime(thread.updatedAt ?? thread.createdAt)} + + + + + )} + + ); +}); + +export function ThreadNavigationSidebar(props: { + readonly width: number; + readonly selectedThreadKey: string | null; + readonly onOpenSettings: () => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onStartNewTask: () => void; +}) { + const insets = useSafeAreaInsets(); + const projects = useProjects(); + const threads = useThreadShells(); + const [searchQuery, setSearchQuery] = useState(""); + const openSwipeableRef = useRef(null); + const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const groups = useMemo( + () => buildThreadNavigationGroups({ projects, threads, searchQuery }), + [projects, searchQuery, threads], + ); + + const backgroundColor = useThemeColor("--color-drawer"); + const borderColor = useThemeColor("--color-border"); + const foregroundColor = useThemeColor("--color-foreground"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const placeholderColor = useThemeColor("--color-placeholder"); + const searchBackgroundColor = useThemeColor("--color-subtle-strong"); + const selectedBackgroundColor = useThemeColor("--color-subtle-strong"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current !== methods) { + openSwipeableRef.current?.close(); + openSwipeableRef.current = methods; + } + }, []); + const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { + if (openSwipeableRef.current === methods) { + openSwipeableRef.current = null; + } + }, []); + + return ( + + + + Threads + + + + + + + + + + + openSwipeableRef.current?.close()} + showsVerticalScrollIndicator={false} + style={styles.threadList} + > + {groups.length === 0 ? ( + + {searchQuery.trim().length > 0 ? "No matching threads" : "No threads yet"} + + ) : ( + groups.map((group) => ( + + + {group.title} + + + {group.threads.length === 0 ? ( + No threads yet + ) : ( + group.threads.map((thread) => { + const threadKey = scopedThreadKey(thread.environmentId, thread.id); + const selected = threadKey === props.selectedThreadKey; + + return ( + + ); + }) + )} + + )) + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + height: 44, + paddingLeft: 18, + paddingRight: 8, + flexDirection: "row", + alignItems: "center", + gap: 2, + }, + searchField: { + height: 36, + marginTop: 6, + marginHorizontal: 14, + paddingLeft: 10, + paddingRight: 5, + borderRadius: 10, + flexDirection: "row", + alignItems: "center", + gap: 7, + }, + searchInput: { + flex: 1, + height: 36, + paddingVertical: 0, + paddingHorizontal: 0, + fontFamily: "DMSans_400Regular", + fontSize: 15, + }, + threadList: { + flex: 1, + }, + threadListContent: { + gap: 18, + paddingHorizontal: 10, + paddingTop: 16, + paddingBottom: 16, + }, + section: { + gap: 4, + }, + threadRow: { + minHeight: 58, + borderRadius: 10, + paddingHorizontal: 10, + paddingVertical: 8, + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + threadRowContainer: { + borderRadius: 10, + overflow: "hidden", + }, + threadText: { + minWidth: 0, + flex: 1, + gap: 2, + }, +}); diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index f8c916974e5..e4cb9ee343f 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,5 +1,6 @@ -import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useMemo, useState } from "react"; +import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; +import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; @@ -12,7 +13,11 @@ import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; -import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes"; +import { + buildThreadFilesNavigation, + buildThreadRoutePath, + buildThreadTerminalNavigation, +} from "../../lib/routes"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { connectionTone } from "../connection/connectionTone"; @@ -38,6 +43,7 @@ import { import { terminalDebugLog } from "../terminal/terminalDebugLog"; import { ThreadDetailScreen } from "./ThreadDetailScreen"; import { ThreadGitControls } from "./ThreadGitControls"; +import { GitOverviewSheet } from "./git/GitOverviewSheet"; import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; import { useAtomCommand } from "../../state/use-atom-command"; import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; @@ -47,6 +53,27 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { threadEnvironment } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; +import { AdaptiveInspectorLayout } from "../layout/adaptive-inspector-layout"; +import { + useAdaptiveWorkspaceLayout, + useAdaptiveWorkspacePaneRole, +} from "../layout/AdaptiveWorkspaceLayout"; +import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane"; +import { + ThreadInspectorContentStack, + type ThreadInspectorMode, +} from "./thread-inspector-content-stack"; + +interface ThreadInspectorSelection { + readonly routeThreadIdentity: string | null; + readonly mode: ThreadInspectorMode; +} + +function InspectorPaneRoleActivation() { + useAdaptiveWorkspacePaneRole("inspector"); + return null; +} function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { @@ -60,7 +87,56 @@ function OpeningThreadLoadingScreen() { return ; } -export function ThreadRouteScreen() { +function ThreadHeaderTitle(props: { + readonly foregroundColor: string; + readonly secondaryForegroundColor: string; + readonly subtitle: string; + readonly title: string; +}) { + return ( + { + // TODO: trigger rename modal + }} + > + + {props.title} + + + {props.subtitle} + + + ); +} + +export function ThreadRouteScreen( + props: { + readonly onReturnToThread?: () => void; + readonly renderInspector?: () => ReactNode; + } = {}, +) { + const { fileInspector, layout, showAuxiliaryPane, toggleAuxiliaryPane } = + useAdaptiveWorkspaceLayout(); + const headerHeight = useHeaderHeight(); const { state: workspaceState } = useWorkspaceState(); const { connectionState } = useRemoteConnectionStatus(); const { onReconnectEnvironment } = useRemoteConnections(); @@ -83,6 +159,28 @@ export function ThreadRouteScreen() { const environmentIdRaw = firstRouteParam(params.environmentId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const threadId = firstRouteParam(params.threadId); + const routeThreadIdentity = + environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null; + const [inspectorSelection, setInspectorSelection] = useState( + () => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null), + ); + const inspectorMode = + inspectorSelection?.routeThreadIdentity === routeThreadIdentity + ? inspectorSelection.mode + : null; + + useFocusEffect( + useCallback(() => { + return () => { + if (props.renderInspector === undefined) { + // Inspectors are contextual to this chat destination. Clear the + // hidden chat copy after a native push so returning from Files, + // Review, or Terminal cannot reserve an empty trailing pane. + setInspectorSelection(null); + } + }; + }, [props.renderInspector]), + ); const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); @@ -104,6 +202,23 @@ export function ThreadRouteScreen() { const iconColor = String(useThemeColor("--color-icon")); const foregroundColor = String(useThemeColor("--color-foreground")); const secondaryFg = String(useThemeColor("--color-foreground-secondary")); + const headerSubtitle = [ + selectedThreadProject?.title ?? null, + selectedEnvironmentConnection?.environmentLabel ?? null, + ] + .filter(Boolean) + .join(" · "); + const renderHeaderTitle = useCallback( + () => ( + + ), + [foregroundColor, headerSubtitle, secondaryFg, selectedThread?.title], + ); /* ─── Git status for native header trigger ───────────────────────── */ const gitStatus = useEnvironmentQuery( @@ -145,8 +260,102 @@ export function ThreadRouteScreen() { const gitActionProgress = useGitActionProgress(gitActionProgressTarget); const handleOpenDrawer = useCallback(() => { - setDrawerVisible(true); + if (!layout.usesSplitView) { + setDrawerVisible(true); + } + }, [layout.usesSplitView]); + + useEffect(() => { + if (layout.usesSplitView) { + setDrawerVisible(false); + } + }, [layout.usesSplitView]); + + const handleOpenGitInspector = useCallback(() => { + setInspectorSelection({ routeThreadIdentity, mode: "git" }); + showAuxiliaryPane("inspector"); + }, [routeThreadIdentity, showAuxiliaryPane]); + const handleOpenFilesInspector = useCallback(() => { + if (!fileInspector.supported || selectedThread === null || selectedThreadCwd === null) { + return; + } + setInspectorSelection({ + routeThreadIdentity, + mode: props.renderInspector === undefined ? "files" : "route", + }); + showAuxiliaryPane("inspector"); + }, [ + fileInspector.supported, + props.renderInspector, + routeThreadIdentity, + selectedThread, + selectedThreadCwd, + showAuxiliaryPane, + ]); + const inspectorToggleActionRef = useRef({ + inspectorMode, + openFilesInspector: handleOpenFilesInspector, + toggleAuxiliaryPane, + }); + inspectorToggleActionRef.current = { + inspectorMode, + openFilesInspector: handleOpenFilesInspector, + toggleAuxiliaryPane, + }; + const handleToggleInspector = useCallback(() => { + const action = inspectorToggleActionRef.current; + if (action.inspectorMode === null) { + action.openFilesInspector(); + return; + } + action.toggleAuxiliaryPane(); }, []); + const handleSelectInspectorFile = useCallback( + (path: string) => { + if (selectedThread === null) { + return; + } + router.push(buildThreadFilesNavigation(selectedThread, path)); + }, + [router, selectedThread], + ); + const GitInspector = useCallback( + () => , + [headerHeight], + ); + const FilesInspector = useCallback( + () => + selectedThread !== null && selectedThreadCwd !== null ? ( + + ) : null, + [ + handleSelectInspectorFile, + headerHeight, + selectedThread, + selectedThreadCwd, + selectedThreadProject?.title, + ], + ); + const renderInspectorStack = useCallback( + () => + inspectorMode === null ? null : ( + + ), + [FilesInspector, GitInspector, inspectorMode, props.renderInspector], + ); + const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; const handleOpenConnectionEditor = useCallback(() => { void router.push("/connections"); @@ -312,15 +521,9 @@ export function ThreadRouteScreen() { }); const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; - const headerSubtitle = [ - selectedThreadProject?.title ?? null, - selectedEnvironmentConnection?.environmentLabel ?? null, - ] - .filter(Boolean) - .join(" · "); - return ( <> + {activeInspectorRenderer ? : null} ( - { - // TODO: trigger rename modal - }} - > - - {selectedThread.title} - - - {headerSubtitle} - - - ), + headerTitle: renderHeaderTitle, }} /> + + {props.onReturnToThread ? ( + + ) : null} + + - - - - setDrawerVisible(false)} - onSelectThread={(thread) => { - router.replace(buildThreadRoutePath(thread)); - }} - onStartNewTask={() => router.push("/new")} - /> - + + + + + {layout.usesSplitView ? null : ( + setDrawerVisible(false)} + onSelectThread={(thread) => { + router.replace(buildThreadRoutePath(thread)); + }} + onStartNewTask={() => router.push("/new")} + /> + )} + + ); } diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 0db7876a774..075f0d916dd 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -23,9 +23,16 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo import { vcsEnvironment } from "../../../state/vcs"; import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; -export function GitOverviewSheet() { +export function GitOverviewSheet( + props: { + readonly headerInset?: number; + readonly presentation?: "sheet" | "inspector"; + } = {}, +) { const router = useRouter(); const insets = useSafeAreaInsets(); + const presentation = props.presentation ?? "sheet"; + const isInspector = presentation === "inspector"; const { environmentId, threadId } = useLocalSearchParams<{ environmentId: EnvironmentId; threadId: ThreadId; @@ -120,10 +127,12 @@ export function GitOverviewSheet() { return; } - router.dismiss(); + if (!isInspector) { + router.dismiss(); + } await gitActions.onRunSelectedThreadGitAction(input); }, - [environmentId, gitActions, gitStatus.data, isDefaultRef, router, threadId], + [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, router, threadId], ); const onPressMenuItem = useCallback( @@ -152,10 +161,24 @@ export function GitOverviewSheet() { ); return ( - - + + - + - Branch + {isInspector ? "Repository" : "Branch"} + + + {currentBranchLabel} - {currentBranchLabel} {statusSummary(gitStatus.data)} @@ -187,12 +212,18 @@ export function GitOverviewSheet() { style={{ flex: 1 }} contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} contentContainerStyle={{ - paddingHorizontal: 20, + paddingHorizontal: isInspector ? 12 : 20, paddingTop: 8, gap: 14, }} > - + {sheetMenuItems.map(({ item, disabledReason }, index) => ( {index > 0 ? ( diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.ios.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.ios.tsx new file mode 100644 index 00000000000..ba9d715bfc8 --- /dev/null +++ b/apps/mobile/src/features/threads/sidebar-header-actions.ios.tsx @@ -0,0 +1,21 @@ +import { View } from "react-native"; + +import { T3HeaderButton } from "../../native/T3HeaderButton.ios"; +import type { SidebarHeaderActionsProps } from "./sidebar-header-actions"; + +export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { + return ( + + + + + ); +} diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.tsx new file mode 100644 index 00000000000..292866c2025 --- /dev/null +++ b/apps/mobile/src/features/threads/sidebar-header-actions.tsx @@ -0,0 +1,65 @@ +import { SymbolView } from "expo-symbols"; +import { Pressable, StyleSheet, View } from "react-native"; + +import { useThemeColor } from "../../lib/useThemeColor"; + +export interface SidebarHeaderActionsProps { + readonly onOpenSettings: () => void; + readonly onStartNewTask: () => void; +} + +function FallbackHeaderButton(props: { + readonly accessibilityLabel: string; + readonly icon: "gearshape" | "square.and.pencil"; + readonly onPress: () => void; +}) { + const iconColor = useThemeColor("--color-icon-muted"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + + return ( + [ + styles.button, + { backgroundColor: pressed ? pressedBackgroundColor : "transparent" }, + ]} + > + + + ); +} + +export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + actions: { + flexDirection: "row", + alignItems: "center", + gap: 2, + }, + button: { + width: 44, + height: 44, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx new file mode 100644 index 00000000000..2c8ec73342d --- /dev/null +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState, type ComponentType, type ReactNode } from "react"; +import { View } from "react-native"; + +export type ThreadInspectorMode = "route" | "git" | "files"; + +const INSPECTOR_PREWARM_DELAY_MS = 350; + +function InspectorContentPane(props: { + readonly children: ReactNode; + readonly mounted: boolean; + readonly visible: boolean; +}) { + if (!props.mounted) { + return null; + } + + return ( + + {props.children} + + ); +} + +export function ThreadInspectorContentStack(props: { + readonly Files: ComponentType; + readonly Git: ComponentType; + readonly mode: ThreadInspectorMode; + readonly Route?: ComponentType; +}) { + const [mountedModes, setMountedModes] = useState>( + () => new Set([props.mode]), + ); + + useEffect(() => { + setMountedModes((current) => { + if (current.has(props.mode)) { + return current; + } + return new Set([...current, props.mode]); + }); + + if (props.mode === "route") { + return; + } + + // The file tree is expensive to detach because UIKit rebuilds its focus + // graph. Keep both chat inspectors alive after the opening animation so a + // later Files/Git switch only changes visibility. + const alternateMode = props.mode === "files" ? "git" : "files"; + const timeout = setTimeout(() => { + setMountedModes((current) => { + if (current.has(alternateMode)) { + return current; + } + return new Set([...current, alternateMode]); + }); + }, INSPECTOR_PREWARM_DELAY_MS); + + return () => clearTimeout(timeout); + }, [props.mode]); + + const Files = props.Files; + const Git = props.Git; + const Route = props.Route; + + return ( + + + + + + + + {Route ? ( + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/threads/thread-navigation-groups.test.ts b/apps/mobile/src/features/threads/thread-navigation-groups.test.ts new file mode 100644 index 00000000000..2cfda2f4865 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-navigation-groups.test.ts @@ -0,0 +1,111 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildThreadNavigationGroups } from "./thread-navigation-groups"; + +const environmentId = EnvironmentId.make("environment-1"); + +function makeProject(input: Pick): EnvironmentProject { + return { + environmentId, + workspaceRoot: `/workspaces/${input.id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + ...input, + }; +} + +function makeThread( + input: Pick & + Partial, +): EnvironmentThreadShell { + return { + environmentId, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + }; +} + +describe("buildThreadNavigationGroups", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const threads = [ + makeThread({ + id: ThreadId.make("older"), + projectId: project.id, + title: "Fix reconnect flow", + updatedAt: "2026-06-02T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("newer"), + projectId: project.id, + title: "Build adaptive sidebar", + updatedAt: "2026-06-03T00:00:00.000Z", + }), + ]; + + it("sorts each group by recent activity", () => { + expect( + buildThreadNavigationGroups({ projects: [project], threads })[0]?.threads.map( + (thread) => thread.id, + ), + ).toEqual(["newer", "older"]); + }); + + it("matches thread titles without dropping their group", () => { + const groups = buildThreadNavigationGroups({ + projects: [project], + threads, + searchQuery: "reconnect", + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.threads.map((thread) => thread.id)).toEqual(["older"]); + }); + + it("keeps every thread when the project title matches", () => { + expect( + buildThreadNavigationGroups({ + projects: [project], + threads, + searchQuery: "t3 code", + })[0]?.threads.map((thread) => thread.id), + ).toEqual(["newer", "older"]); + }); + + it("excludes archived threads from the navigation sidebar", () => { + const archived = makeThread({ + id: ThreadId.make("archived"), + projectId: project.id, + title: "Archived work", + archivedAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + }); + + expect( + buildThreadNavigationGroups({ + projects: [project], + threads: [...threads, archived], + })[0]?.threads.map((thread) => thread.id), + ).toEqual(["newer", "older"]); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-navigation-groups.ts b/apps/mobile/src/features/threads/thread-navigation-groups.ts new file mode 100644 index 00000000000..1531f6deb67 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-navigation-groups.ts @@ -0,0 +1,64 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; + +import { groupProjectsByRepository } from "../../lib/repositoryGroups"; + +export interface ThreadNavigationGroup { + readonly key: string; + readonly title: string; + readonly threads: ReadonlyArray; +} + +const threadActivityOrder = Order.mapInput( + Order.Struct({ + activityAt: Order.flip(Order.Number), + title: Order.String, + }), + (thread: EnvironmentThreadShell) => ({ + activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), + title: thread.title, + }), +); + +export function buildThreadNavigationGroups(input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly searchQuery?: string; +}): ReadonlyArray { + const query = input.searchQuery?.trim().toLocaleLowerCase() ?? ""; + const activeThreads = input.threads.filter((thread) => thread.archivedAt === null); + + return groupProjectsByRepository({ ...input, threads: activeThreads }).flatMap((group) => { + const threads = Arr.sort( + group.projects.flatMap((projectGroup) => projectGroup.threads), + threadActivityOrder, + ); + const title = group.projects[0]?.project.title ?? group.title; + const groupMatches = + query.length === 0 || + title.toLocaleLowerCase().includes(query) || + group.title.toLocaleLowerCase().includes(query) || + group.projects.some((projectGroup) => + projectGroup.project.title.toLocaleLowerCase().includes(query), + ); + const matchingThreads = groupMatches + ? threads + : threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query)); + + if (query.length > 0 && matchingThreads.length === 0) { + return []; + } + + return [ + { + key: group.key, + title, + threads: matchingThreads, + }, + ]; + }); +} diff --git a/apps/mobile/src/lib/adaptive-navigation.test.ts b/apps/mobile/src/lib/adaptive-navigation.test.ts new file mode 100644 index 00000000000..7f8ce80a1ae --- /dev/null +++ b/apps/mobile/src/lib/adaptive-navigation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isBaseThreadRoute, + resolveFileSelectionNavigationAction, + resolveThreadSelectionNavigationAction, +} from "./adaptive-navigation"; + +describe("isBaseThreadRoute", () => { + it("recognizes only the thread detail route", () => { + expect(isBaseThreadRoute("/threads/environment/thread")).toBe(true); + expect(isBaseThreadRoute("/threads/environment/thread/")).toBe(true); + expect(isBaseThreadRoute("/threads/environment/thread/files")).toBe(false); + expect(isBaseThreadRoute("/threads/environment/thread/review")).toBe(false); + }); +}); + +describe("resolveThreadSelectionNavigationAction", () => { + it("updates params when a persistent sidebar selects a peer thread", () => { + expect( + resolveThreadSelectionNavigationAction({ + usesSplitView: true, + pathname: "/threads/environment/thread", + }), + ).toBe("set-params"); + }); + + it("replaces nested thread content when a persistent sidebar selects a peer", () => { + expect( + resolveThreadSelectionNavigationAction({ + usesSplitView: true, + pathname: "/threads/environment/thread/files/path", + }), + ).toBe("replace"); + }); + + it("pushes compact list selections onto the native stack", () => { + expect( + resolveThreadSelectionNavigationAction({ + usesSplitView: false, + pathname: "/threads/environment/thread", + }), + ).toBe("push"); + }); +}); + +describe("resolveFileSelectionNavigationAction", () => { + it("replaces the wide file browser with the selected preview", () => { + expect(resolveFileSelectionNavigationAction({ hasPersistentFileInspector: true })).toBe( + "replace", + ); + }); + + it("pushes a preview above the compact file browser", () => { + expect(resolveFileSelectionNavigationAction({ hasPersistentFileInspector: false })).toBe( + "push", + ); + }); +}); diff --git a/apps/mobile/src/lib/adaptive-navigation.ts b/apps/mobile/src/lib/adaptive-navigation.ts new file mode 100644 index 00000000000..0d0fa59f1fb --- /dev/null +++ b/apps/mobile/src/lib/adaptive-navigation.ts @@ -0,0 +1,33 @@ +export type AdaptiveNavigationAction = "push" | "replace" | "set-params"; + +const BASE_THREAD_ROUTE_PATTERN = /^\/threads\/[^/]+\/[^/]+\/?$/; + +export function isBaseThreadRoute(pathname: string): boolean { + return BASE_THREAD_ROUTE_PATTERN.test(pathname); +} + +/** + * A persistent sidebar selects a peer destination in place. A compact list + * drills into a new destination so the native back stack remains available. + */ +export function resolveThreadSelectionNavigationAction(input: { + readonly usesSplitView: boolean; + readonly pathname: string; +}): AdaptiveNavigationAction { + if (!input.usesSplitView) { + return "push"; + } + + return isBaseThreadRoute(input.pathname) ? "set-params" : "replace"; +} + +/** + * On regular-width layouts, the file browser and preview occupy one workspace + * destination. Replacing the browser route keeps a single back step to chat. + * Compact layouts retain the browser as the previous stack screen. + */ +export function resolveFileSelectionNavigationAction(input: { + readonly hasPersistentFileInspector: boolean; +}): AdaptiveNavigationAction { + return input.hasPersistentFileInspector ? "replace" : "push"; +} diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts new file mode 100644 index 00000000000..58fd95c3e89 --- /dev/null +++ b/apps/mobile/src/lib/layout.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + deriveCenteredContentHorizontalPadding, + deriveFileInspectorPaneLayout, + deriveLayout, + deriveStableFormSheetDetent, + deriveWorkspacePaneLayout, + SPLIT_LAYOUT_MIN_HEIGHT, + SPLIT_LAYOUT_MIN_WIDTH, +} from "./layout"; + +describe("deriveCenteredContentHorizontalPadding", () => { + it("keeps the minimum padding while the viewport fits the reading width", () => { + expect( + deriveCenteredContentHorizontalPadding({ + viewportWidth: 744, + maxContentWidth: 960, + minimumPadding: 20, + }), + ).toBe(20); + }); + + it("centers only the content inside a wider full-width scroll host", () => { + expect( + deriveCenteredContentHorizontalPadding({ + viewportWidth: 1_032, + maxContentWidth: 960, + minimumPadding: 20, + }), + ).toBe(56); + }); + + it("supports unconstrained compact content", () => { + expect( + deriveCenteredContentHorizontalPadding({ + viewportWidth: 430, + maxContentWidth: null, + minimumPadding: 16, + }), + ).toBe(16); + }); +}); + +describe("deriveLayout", () => { + it.each([ + { name: "small iPhone portrait", width: 375, height: 667 }, + { name: "large iPhone portrait", width: 430, height: 932 }, + { name: "small iPhone landscape", width: 667, height: 375 }, + { name: "large iPhone landscape", width: 932, height: 430 }, + { name: "short wide window", width: 1_024, height: 599 }, + { name: "narrow tall window", width: 719, height: 1_024 }, + ])("keeps a $name in the compact shell", ({ width, height }) => { + expect(deriveLayout({ width, height })).toEqual({ + variant: "compact", + usesSplitView: false, + listPaneWidth: null, + shellPadding: 0, + }); + }); + + it.each([ + { name: "small tablet portrait", width: 744, height: 1_133 }, + { name: "tablet landscape", width: 1_024, height: 768 }, + { name: "large resizable window", width: 1_366, height: 1_024 }, + { name: "foldable-sized window", width: 800, height: 700 }, + ])("uses the split shell for a $name", ({ width, height }) => { + expect(deriveLayout({ width, height })).toMatchObject({ + variant: "split", + usesSplitView: true, + }); + }); + + it("switches only after both space requirements are met", () => { + expect( + deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant, + ).toBe("split"); + expect( + deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH - 1, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant, + ).toBe("compact"); + expect( + deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT - 1 }).variant, + ).toBe("compact"); + }); + + it("keeps the sidebar within usable native-column bounds", () => { + expect(deriveLayout({ width: 720, height: 1_000 }).listPaneWidth).toBe(280); + expect(deriveLayout({ width: 1_024, height: 768 }).listPaneWidth).toBe(328); + expect(deriveLayout({ width: 1_600, height: 1_000 }).listPaneWidth).toBe(380); + }); +}); + +describe("deriveWorkspacePaneLayout", () => { + it("keeps the auxiliary pane out of a standard iPad detail column", () => { + const layout = deriveLayout({ width: 1_194, height: 834 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_194, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + }), + ).toEqual({ + primarySidebarVisible: true, + primarySidebarSuppressedByAuxiliary: false, + contentPaneWidth: 814, + supportsAuxiliaryPane: false, + auxiliaryPaneVisible: false, + auxiliaryPaneWidth: null, + }); + }); + + it("offers an auxiliary pane when maximizing a standard iPad landscape window", () => { + const layout = deriveLayout({ width: 1_194, height: 834 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_194, + primarySidebarPreferredVisible: false, + auxiliaryPanePreferredVisible: true, + }), + ).toEqual({ + primarySidebarVisible: false, + primarySidebarSuppressedByAuxiliary: false, + contentPaneWidth: 1_194, + supportsAuxiliaryPane: true, + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: 320, + }); + }); + + it("prioritizes a trailing file inspector over the thread sidebar at medium widths", () => { + const layout = deriveLayout({ width: 1_024, height: 1_366 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_024, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + auxiliaryPaneRole: "inspector", + }), + ).toEqual({ + primarySidebarVisible: false, + primarySidebarSuppressedByAuxiliary: true, + contentPaneWidth: 1_024, + supportsAuxiliaryPane: true, + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: 287, + }); + }); + + it("keeps threads, content, and the file inspector visible in a large landscape window", () => { + const layout = deriveLayout({ width: 1_366, height: 1_024 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_366, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + auxiliaryPaneRole: "inspector", + }), + ).toEqual({ + primarySidebarVisible: true, + primarySidebarSuppressedByAuxiliary: false, + contentPaneWidth: 986, + supportsAuxiliaryPane: true, + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: 320, + }); + }); + + it("restores the thread sidebar when the file inspector is hidden", () => { + const layout = deriveLayout({ width: 1_024, height: 1_366 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_024, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: false, + auxiliaryPaneRole: "inspector", + }), + ).toMatchObject({ + primarySidebarVisible: true, + primarySidebarSuppressedByAuxiliary: false, + auxiliaryPaneVisible: false, + }); + }); + + it("keeps file navigation on the native stack below the inspector breakpoint", () => { + const layout = deriveLayout({ width: 744, height: 1_133 }); + + expect(deriveFileInspectorPaneLayout({ layout, viewportWidth: 744 })).toEqual({ + supported: false, + width: null, + }); + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 744, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + auxiliaryPaneRole: "inspector", + }), + ).toMatchObject({ + primarySidebarVisible: true, + supportsAuxiliaryPane: false, + auxiliaryPaneVisible: false, + }); + }); + + it("supports three visible columns in a sufficiently large window", () => { + const layout = deriveLayout({ width: 1_366, height: 1_024 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_366, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + }), + ).toMatchObject({ + primarySidebarVisible: true, + contentPaneWidth: 986, + supportsAuxiliaryPane: true, + auxiliaryPaneVisible: true, + auxiliaryPaneWidth: 276, + }); + }); + + it("respects a hidden auxiliary-pane preference", () => { + const layout = deriveLayout({ width: 1_366, height: 1_024 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_366, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: false, + }).auxiliaryPaneVisible, + ).toBe(false); + }); + + it("never exposes workspace panes in compact layouts", () => { + const layout = deriveLayout({ width: 430, height: 932 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 430, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: true, + }), + ).toMatchObject({ + primarySidebarVisible: false, + supportsAuxiliaryPane: false, + auxiliaryPaneVisible: false, + auxiliaryPaneWidth: null, + }); + }); +}); + +describe("deriveStableFormSheetDetent", () => { + it.each([ + { height: 1_194, expected: 0.62 }, + { height: 834, expected: 0.863 }, + { height: 600, expected: 0.893 }, + { height: 0, expected: 0.92 }, + ])("derives a stable sheet detent for height $height", ({ height, expected }) => { + expect(deriveStableFormSheetDetent(height)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 2ae4314fdba..dab90a79ac5 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -2,6 +2,31 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } +/** + * Use available space, not device or orientation labels, to choose the shell. + * + * The height floor deliberately keeps every current iPhone in the compact shell + * when it rotates to landscape, while still allowing iPad and foldable-sized + * windows to adopt the persistent sidebar as they resize. + */ +export const SPLIT_LAYOUT_MIN_WIDTH = 720; +export const SPLIT_LAYOUT_MIN_HEIGHT = 600; + +const SPLIT_SIDEBAR_MIN_WIDTH = 280; +const SPLIT_SIDEBAR_MAX_WIDTH = 380; + +export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; +export const CHAT_CONTENT_MAX_WIDTH = 960; + +const AUXILIARY_PANE_MIN_WIDTH = 260; +const AUXILIARY_PANE_MAX_WIDTH = 320; +const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; +const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; +const STABLE_FORM_SHEET_MAX_HEIGHT = 720; +const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64; +const STABLE_FORM_SHEET_MIN_DETENT = 0.62; +const STABLE_FORM_SHEET_MAX_DETENT = 0.92; + export type LayoutVariant = "compact" | "split"; export interface Layout { @@ -11,10 +36,25 @@ export interface Layout { readonly shellPadding: number; } +export interface WorkspacePaneLayout { + readonly primarySidebarVisible: boolean; + readonly primarySidebarSuppressedByAuxiliary: boolean; + readonly contentPaneWidth: number; + readonly supportsAuxiliaryPane: boolean; + readonly auxiliaryPaneVisible: boolean; + readonly auxiliaryPaneWidth: number | null; +} + +export interface FileInspectorPaneLayout { + readonly supported: boolean; + readonly width: number | null; +} + +export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; + export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { const { width, height } = input; - const shortestEdge = Math.min(width, height); - const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700); + const wideEnoughForSplit = width >= SPLIT_LAYOUT_MIN_WIDTH && height >= SPLIT_LAYOUT_MIN_HEIGHT; if (!wideEnoughForSplit) { return { @@ -28,7 +68,127 @@ export function deriveLayout(input: { readonly width: number; readonly height: n return { variant: "split", usesSplitView: true, - listPaneWidth: clamp(Math.round(width * 0.34), 320, 420), - shellPadding: width >= 1180 ? 20 : 14, + listPaneWidth: clamp( + Math.round(width * 0.32), + SPLIT_SIDEBAR_MIN_WIDTH, + SPLIT_SIDEBAR_MAX_WIDTH, + ), + shellPadding: 0, + }; +} + +export function deriveWorkspacePaneLayout(input: { + readonly layout: Layout; + readonly viewportWidth: number; + readonly primarySidebarPreferredVisible: boolean; + readonly auxiliaryPanePreferredVisible: boolean; + readonly auxiliaryPaneRole?: WorkspaceAuxiliaryPaneRole; +}): WorkspacePaneLayout { + const viewportWidth = Math.max(0, input.viewportWidth); + const auxiliaryPaneRole = input.auxiliaryPaneRole ?? "supplementary"; + const preferredPrimarySidebarVisible = + input.layout.usesSplitView && input.primarySidebarPreferredVisible; + const preferredPrimarySidebarWidth = preferredPrimarySidebarVisible + ? (input.layout.listPaneWidth ?? 0) + : 0; + + if (auxiliaryPaneRole === "inspector") { + const fileInspector = deriveFileInspectorPaneLayout({ + layout: input.layout, + viewportWidth, + }); + const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible; + const primarySidebarSuppressedByAuxiliary = + auxiliaryPaneVisible && + fileInspector.width !== null && + input.layout.listPaneWidth !== null && + viewportWidth - input.layout.listPaneWidth - fileInspector.width < + FILE_INSPECTOR_MIN_MAIN_WIDTH; + const primarySidebarVisible = + preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary; + const primarySidebarWidth = primarySidebarVisible ? (input.layout.listPaneWidth ?? 0) : 0; + + return { + primarySidebarVisible, + primarySidebarSuppressedByAuxiliary, + contentPaneWidth: Math.max(0, viewportWidth - primarySidebarWidth), + supportsAuxiliaryPane: fileInspector.supported, + auxiliaryPaneVisible, + auxiliaryPaneWidth: fileInspector.width, + }; + } + + const contentPaneWidth = Math.max(0, viewportWidth - preferredPrimarySidebarWidth); + const supportsAuxiliaryPane = + input.layout.usesSplitView && contentPaneWidth >= AUXILIARY_PANE_MIN_CONTENT_WIDTH; + const auxiliaryPaneVisible = supportsAuxiliaryPane && input.auxiliaryPanePreferredVisible; + + return { + primarySidebarVisible: preferredPrimarySidebarVisible, + primarySidebarSuppressedByAuxiliary: false, + contentPaneWidth, + supportsAuxiliaryPane, + auxiliaryPaneVisible, + auxiliaryPaneWidth: supportsAuxiliaryPane + ? clamp( + Math.round(contentPaneWidth * 0.28), + AUXILIARY_PANE_MIN_WIDTH, + AUXILIARY_PANE_MAX_WIDTH, + ) + : null, }; } + +export function deriveFileInspectorPaneLayout(input: { + readonly layout: Layout; + readonly viewportWidth: number; +}): FileInspectorPaneLayout { + const viewportWidth = Math.max(0, input.viewportWidth); + const supported = + input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH; + + return { + supported, + width: supported + ? clamp(Math.round(viewportWidth * 0.28), AUXILIARY_PANE_MIN_WIDTH, AUXILIARY_PANE_MAX_WIDTH) + : null, + }; +} + +export function deriveCenteredContentHorizontalPadding(input: { + readonly viewportWidth: number; + readonly maxContentWidth: number | null; + readonly minimumPadding: number; +}): number { + const viewportWidth = Number.isFinite(input.viewportWidth) ? Math.max(0, input.viewportWidth) : 0; + const minimumPadding = Number.isFinite(input.minimumPadding) + ? Math.max(0, input.minimumPadding) + : 0; + + if ( + input.maxContentWidth === null || + !Number.isFinite(input.maxContentWidth) || + input.maxContentWidth <= 0 + ) { + return minimumPadding; + } + + return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); +} + +export function deriveStableFormSheetDetent(containerHeight: number): number { + if (!Number.isFinite(containerHeight) || containerHeight <= 0) { + return STABLE_FORM_SHEET_MAX_DETENT; + } + + const targetHeight = Math.min( + STABLE_FORM_SHEET_MAX_HEIGHT, + Math.max(0, containerHeight - STABLE_FORM_SHEET_VERTICAL_MARGIN), + ); + const detent = clamp( + targetHeight / containerHeight, + STABLE_FORM_SHEET_MIN_DETENT, + STABLE_FORM_SHEET_MAX_DETENT, + ); + return Math.round(detent * 1_000) / 1_000; +} diff --git a/apps/mobile/src/native/T3HeaderButton.ios.tsx b/apps/mobile/src/native/T3HeaderButton.ios.tsx new file mode 100644 index 00000000000..74908abd16c --- /dev/null +++ b/apps/mobile/src/native/T3HeaderButton.ios.tsx @@ -0,0 +1,26 @@ +import { requireNativeView } from "expo"; +import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; + +interface NativeHeaderButtonProps extends ViewProps { + readonly label: string; + readonly systemImage: "gearshape" | "square.and.pencil"; + readonly onTriggered: (event: NativeSyntheticEvent>) => void; +} + +const NativeHeaderButton = requireNativeView("T3NativeControls"); + +export function T3HeaderButton(props: { + readonly accessibilityLabel: string; + readonly icon: NativeHeaderButtonProps["systemImage"]; + readonly onPress: () => void; + readonly style?: StyleProp; +}) { + return ( + + ); +}