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 (
+
+ );
+}