diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.37.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.37.tgz deleted file mode 100644 index 0d0076c8a3c6..000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.37.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.43.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.43.tgz new file mode 100644 index 000000000000..9406a8d7fcff Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.43.tgz differ diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index 3a924af4889e..315d25c13007 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -63,12 +63,11 @@ import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView -private val TABS = listOf("threads", "pullRequests", "settings") - class T3WorkspaceNavigationModule : Module() { override fun definition() = ModuleDefinition { Name("T3WorkspaceNavigation") View(T3WorkspaceNavigationView::class) { + Prop("tabs") { view: T3WorkspaceNavigationView, value: List -> view.tabs = value } Prop("selectedTab") { view: T3WorkspaceNavigationView, value: String -> view.selectedTab = value } @@ -121,6 +120,7 @@ class T3WorkspaceNavigationView( private val onTabSelected by EventDispatcher() private val onNewThread by EventDispatcher() private val onOverlayChange by EventDispatcher() + var tabs by mutableStateOf(listOf("threads", "pullRequests", "usage", "settings")) var selectedTab by mutableStateOf("threads") var rail by mutableStateOf(false) var dark by mutableStateOf(false) @@ -175,7 +175,7 @@ class T3WorkspaceNavigationView( tonalElevation = androidx.compose.ui.unit.Dp(0f), windowInsets = WindowInsets(0), ) { - TABS.forEach { tab -> + tabs.forEach { tab -> NavigationBarItem( selected = selectedTab == tab, onClick = { onTabSelected(mapOf("tab" to tab)) }, @@ -267,12 +267,14 @@ class T3WorkspaceNavigationView( ) } } - newThreadButton(fraction) { - expanded = false - onNewThread(emptyMap()) + if (selectedTab == "threads") { + newThreadButton(fraction) { + expanded = false + onNewThread(emptyMap()) + } } Spacer(Modifier.weight(1f)) - TABS.forEach { tab -> + tabs.forEach { tab -> railItem(tab, fraction) { expanded = false onTabSelected(mapOf("tab" to tab)) @@ -356,7 +358,8 @@ class T3WorkspaceNavigationView( private fun tabTitle(tab: String) = when (tab) { "threads" -> "Chats" - "pullRequests" -> "PRs" + "pullRequests" -> "Pulls" + "usage" -> "Usage" else -> "Settings" } @@ -368,6 +371,7 @@ class T3WorkspaceNavigationView( when (tab) { "threads" -> R.drawable.t3_chats "pullRequests" -> R.drawable.t3_pull_requests + "usage" -> R.drawable.t3_usage else -> R.drawable.t3_settings } ) diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml new file mode 100644 index 000000000000..da09809d177f --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_usage.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 81a892c8becf..bbd71e72db0e 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -34,9 +34,12 @@ public final class T3NativeControlsModule: Module { View(T3SidebarTabBarView.self) { ViewName("SidebarTabBar") - Events("onSelectionChange") - Prop("selectedIndex") { (view: T3SidebarTabBarView, value: Int) in view.selectedIndex = value } + Events("onSelectionChange", "onCompose") + Prop("tabs") { (view: T3SidebarTabBarView, value: [String]) in view.tabs = value } + Prop("selectedTab") { (view: T3SidebarTabBarView, value: String) in view.selectedTab = value } Prop("selectedColor") { (view: T3SidebarTabBarView, value: UIColor) in view.selectedColor = value } + Prop("showsCompose") { (view: T3SidebarTabBarView, value: Bool) in view.showsCompose = value } + Prop("compact") { (view: T3SidebarTabBarView, value: Bool) in view.compact = value } Prop("appearance") { (view: T3SidebarTabBarView, value: String) in view.appearance = value } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift index 3bfd730facd1..2f9fa98f0d5a 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift @@ -1,37 +1,139 @@ import ExpoModulesCore import UIKit +private final class WorkspaceTabBar: UITabBar { + var onContentLayout: ((CGRect) -> Void)? + + override func layoutSubviews() { + super.layoutSubviews() + let row = tabButtons(in: self).reduce(CGRect.null) { $0.union(convert($1.bounds, from: $1)) } + if !row.isNull { onContentLayout?(row) } + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + invalidateIntrinsicContentSize() + } + + private func tabButtons(in view: UIView) -> [UIView] { + view.subviews.filter { !$0.isHidden }.flatMap { subview in + if subview is UIControl, subview.accessibilityIdentifier?.hasPrefix("workspace-tab-") == true { + return [subview] + } + return tabButtons(in: subview) + } + } +} + final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { let onSelectionChange = EventDispatcher() - private let tabBar = UITabBar() - var selectedIndex = 0 { didSet { updateSelection() } } + let onCompose = EventDispatcher() + private let tabBar = WorkspaceTabBar() + private let composeButton = UIButton(type: .system) + private lazy var tabBarTrailing = tabBar.trailingAnchor.constraint(equalTo: trailingAnchor) + private var itemsByTab: [String: UITabBarItem] = [:] + var tabs = ["threads", "pullRequests", "usage", "settings"] { + didSet { if tabs != oldValue { updateItems() } } + } + var selectedTab = "threads" { didSet { updateSelection() } } var selectedColor: UIColor = .systemBlue { didSet { tabBar.tintColor = selectedColor } } + var compact = false { + didSet { + tabBarTrailing.constant = showsCompose ? (compact ? -64 : -76) : 0 + tabBar.setNeedsLayout() + } + } + var showsCompose = false { + didSet { + composeButton.isHidden = !showsCompose + tabBarTrailing.constant = showsCompose ? (compact ? -64 : -76) : 0 + setNeedsLayout() + } + } var appearance = "light" { didSet { overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light } } required init(appContext: AppContext? = nil) { super.init(appContext: appContext) - let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:)) - let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) - tabBar.items = [ - UITabBarItem(title: "Threads", image: tabIcon(UIImage(systemName: "text.bubble")), tag: 0), - UITabBarItem(title: "Pulls", image: tabIcon(pullRequestIcon), tag: 1), - ] - tabBar.items?.last?.accessibilityLabel = "Pull requests" - tabBar.items?.first?.selectedImage = tabIcon(UIImage(systemName: "text.bubble.fill")) tabBar.itemPositioning = .fill + // iPad's regular bar uses a compressed inline row even with fill positioning. + // This floating sidebar control needs the same full-size items as a phone bar. + if #available(iOS 17.0, *) { + tabBar.traitOverrides.horizontalSizeClass = .compact + } tabBar.delegate = self + tabBar.translatesAutoresizingMaskIntoConstraints = false addSubview(tabBar) - updateSelection() + NSLayoutConstraint.activate([ + tabBar.leadingAnchor.constraint(equalTo: leadingAnchor), + tabBarTrailing, + tabBar.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + // UIKit's floating row changes with window geometry. Follow its laid-out items + // instead of estimating their position from a second set of safe-area updates. + tabBar.onContentLayout = { [weak self] frame in self?.layoutCompose(alongside: frame) } + composeButton.accessibilityLabel = "New chat" + composeButton.addAction(UIAction { [weak self] _ in self?.onCompose() }, for: .touchUpInside) + composeButton.isHidden = true + addSubview(composeButton) + configureCompose() + prepareItems() + updateItems() } - override func layoutSubviews() { - super.layoutSubviews() - tabBar.frame = bounds + private func configureCompose() { + var configuration: UIButton.Configuration + if #available(iOS 26.0, *) { + configuration = .glass() + } else { + configuration = .gray() + } + configuration.cornerStyle = .capsule + configuration.baseForegroundColor = .label + configuration.image = UIImage(systemName: "square.and.pencil") + configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 24) + composeButton.configuration = configuration + } + + private func prepareItems() { + let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:)) + let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) + let labels = ["threads": "Chats", "pullRequests": "PRs", "usage": "Usage", "settings": "Settings"] + let symbols = ["threads": "text.bubble", "usage": "chart.bar.xaxis", "settings": "gearshape"] + for (tab, label) in labels { + let icon = tab == "pullRequests" ? pullRequestIcon : UIImage(systemName: symbols[tab] ?? "circle") + let item = UITabBarItem(title: nil, image: tabIcon(icon), tag: 0) + item.accessibilityLabel = label + item.accessibilityIdentifier = "workspace-tab-\(tab)" + if tab == "threads" { item.selectedImage = tabIcon(UIImage(systemName: "text.bubble.fill")) } + itemsByTab[tab] = item + } + } + + private func updateItems() { + tabBar.items = tabs.enumerated().compactMap { index, tab in + guard let item = itemsByTab[tab] else { return nil } + item.tag = index + return item + } updateSelection() } + private func layoutCompose(alongside frame: CGRect) { + guard showsCompose, frame.height > 0 else { return } + let row = convert(frame, from: tabBar) + let size = row.height + 8 + composeButton.frame = CGRect(x: row.maxX + 16, y: row.midY - size / 2, width: size, height: size) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if showsCompose, let target = composeButton.hitTest(convert(point, to: composeButton), with: event) { + return target + } + return tabBar.hitTest(convert(point, to: tabBar), with: event) + } + override func didMoveToWindow() { super.didMoveToWindow() updateSelection() @@ -49,12 +151,13 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { } private func updateSelection() { - guard let items = tabBar.items, items.indices.contains(selectedIndex) else { return } - guard tabBar.selectedItem !== items[selectedIndex] else { return } - tabBar.selectedItem = items[selectedIndex] + guard let item = itemsByTab[selectedTab], tabBar.items?.contains(item) == true else { return } + guard tabBar.selectedItem !== item else { return } + tabBar.selectedItem = item } func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { - onSelectionChange(["index": item.tag]) + guard tabs.indices.contains(item.tag) else { return } + onSelectionChange(["tab": tabs[item.tag]]) } } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b94b8fc0e8d5..2225adf5273b 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -134,7 +134,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.37.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.43.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index d89baa82e402..bcd1a97ce529 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -85,6 +85,7 @@ import { } from "./features/threads/ThreadSettingsSheet"; import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; +import { SettingsNavigationRouteScreen } from "./features/settings/SettingsNavigationRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; @@ -237,6 +238,7 @@ const SettingsContentStack = createV5SheetStackNavigator({ // Android draws its own app bar here. Hidden only from inside the screen, the native one // showed for the first frame of the Settings tab and pushed the page down. headerShown: Platform.OS !== "android", + headerBackVisible: false, }, }), SettingsEnvironments: createNativeStackScreen({ @@ -305,6 +307,11 @@ const SettingsContentStack = createV5SheetStackNavigator({ title: "Archived Threads", }, }), + SettingsNavigation: createNativeStackScreen({ + screen: SettingsNavigationRouteScreen, + linking: "navigation-tabs", + options: { title: "Navigation tabs" }, + }), SettingsAppearance: createNativeStackScreen({ screen: SettingsAppearanceRouteScreen, linking: "appearance", @@ -975,8 +982,8 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...NESTED_NAVIGATOR_OPTIONS, gestureEnabled: true, headerShown: false, - // Android opens Settings as a top-level tab, so it swaps like the other tabs. - ...(Platform.OS === "android" ? { animation: "none" as const } : null), + presentation: "card", + animation: "none", }, }), SettingsLegal: createNativeStackScreen({ @@ -1066,42 +1073,15 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig const { width, height } = useWindowDimensions(); const nativeMetrics = useNativeLayoutMetrics(); const usesSplitView = deriveLayout({ width, height, nativeMetrics }).usesSplitView; - const usesWorkspaceFlowScreens = Platform.OS === "android" || usesSplitView; - const flowPresentations = useRef(new Map()); return ( } - screenListeners={{ - state: ({ data }) => { - const keys = new Set(data.state.routes.map((route) => route.key)); - for (const key of flowPresentations.current.keys()) { - if (!keys.has(key)) flowPresentations.current.delete(key); - } - }, - }} - screenOptions={({ route }) => { - if (route.name !== "SettingsSheet") { - return Platform.OS === "android" - ? { animation: androidWorkspaceDestinationAnimation(route.name, usesSplitView) } - : {}; - } - - // Moving an open flow between a sheet and a split column remounts its nested - // navigator. Keep its presentation until dismissal so folding preserves the settings flow. - let usesCard = flowPresentations.current.get(route.key); - if (usesCard === undefined) { - usesCard = usesWorkspaceFlowScreens; - flowPresentations.current.set(route.key, usesCard); - } - return usesCard - ? { presentation: "card" } - : { - presentation: "formSheet", - sheetAllowedDetents: [1], - sheetGrabberVisible: true, - }; - }} + screenOptions={({ route }) => + Platform.OS === "android" && route.name !== "SettingsSheet" + ? { animation: androidWorkspaceDestinationAnimation(route.name, usesSplitView) } + : {} + } /> ); }); diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 1dd939b36cb6..142f4d10ec96 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -17,6 +17,7 @@ import { projectAutoMonogram, projectIconColorClassNames, resolveProjectIconGlyph, + resolveProjectIconSymbol, type ProjectIconGlyph, } from "../lib/projectIcon"; @@ -40,6 +41,7 @@ export const ProjectFavicon = memo(function ProjectFavicon(props: { readonly projectIcon?: ProjectIconOverride | null; }) { const size = props.size ?? 42; + const symbol = resolveProjectIconSymbol(props.projectIcon); const glyph = resolveProjectIconGlyph(props.projectIcon, props.projectTitle); const faviconUrl = useAtomValue( props.workspaceRoot == null || glyph !== null @@ -74,6 +76,17 @@ export const ProjectFavicon = memo(function ProjectFavicon(props: { [renderableFaviconUrl, props.environmentId, props.workspaceRoot, props.faviconPath], ); + if (symbol !== null) { + return ( + + ); + } + if (glyph !== null) { return ; } diff --git a/apps/mobile/src/components/ui/ScreenScrollView.tsx b/apps/mobile/src/components/ui/ScreenScrollView.tsx index 9a83807ec55f..684d2e3bdf51 100644 --- a/apps/mobile/src/components/ui/ScreenScrollView.tsx +++ b/apps/mobile/src/components/ui/ScreenScrollView.tsx @@ -1,11 +1,19 @@ -import type { ComponentProps } from "react"; +import { createContext, use, type ComponentProps } from "react"; import { Platform } from "react-native"; import { NativeScrollView as ScrollView } from "../../native/NativeScrollViewMarker"; +export const ScreenScrollViewBottomInset = createContext(0); + export function ScreenScrollView(props: ComponentProps) { + const bottomInset = use(ScreenScrollViewBottomInset); return ( 0 + ? { ...props.contentInset, bottom: (props.contentInset?.bottom ?? 0) + bottomInset } + : props.contentInset + } contentContainerStyle={[ props.contentContainerStyle, Platform.OS === "android" && { diff --git a/apps/mobile/src/features/home/home-thread-navigation.test.ts b/apps/mobile/src/features/home/home-thread-navigation.test.ts index 820a66665054..d02d602767af 100644 --- a/apps/mobile/src/features/home/home-thread-navigation.test.ts +++ b/apps/mobile/src/features/home/home-thread-navigation.test.ts @@ -21,6 +21,10 @@ function loadRouters() { vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Keyboard: { dismiss: vi.fn() } })); +vi.mock("../workspace/useAndroidWorkspaceSelection", () => ({ + useAndroidWorkspaceSelection: vi.fn(), +})); + vi.mock("@react-navigation/native", () => { const { CommonActions, StackActions } = loadRouters(); return { CommonActions, StackActions }; diff --git a/apps/mobile/src/features/home/home-thread-navigation.ts b/apps/mobile/src/features/home/home-thread-navigation.ts index 94948bb8f5ac..3d750260d6f4 100644 --- a/apps/mobile/src/features/home/home-thread-navigation.ts +++ b/apps/mobile/src/features/home/home-thread-navigation.ts @@ -8,6 +8,7 @@ import { import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { useCallback, useEffect, useRef } from "react"; import { Platform } from "react-native"; +import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; type ThreadSelection = Pick; @@ -53,6 +54,7 @@ export function useHomeThreadRouteSelection() { const navigation = useNavigation>(); const dismissingRouteKey = useRef(null); + const { layout, setPrimarySidebarVisible } = useAdaptiveWorkspaceLayout(); const selectAndroidWorkspace = useAndroidWorkspaceSelection(); useEffect(() => { @@ -85,6 +87,7 @@ export function useHomeThreadRouteSelection() { }); return; } + if (layout.primarySidebarOverlaysContent) setPrimarySidebarVisible(false); navigation.dispatch((state) => createHomeThreadRouteAction({ state, @@ -93,7 +96,12 @@ export function useHomeThreadRouteSelection() { }), ); }, - [navigation, selectAndroidWorkspace], + [ + layout.primarySidebarOverlaysContent, + navigation, + selectAndroidWorkspace, + setPrimarySidebarVisible, + ], ); } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index bec83816b585..3cfeff980e5c 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -1,3 +1,4 @@ +import { AdaptiveWorkspaceContext, useAdaptiveWorkspaceLayout } from "./adaptive-workspace-context"; import { createDeferredNavigation } from "./deferred-navigation"; import { uuidv4 } from "../../lib/uuid"; import type { @@ -15,7 +16,6 @@ import { useNavigation, } from "@react-navigation/native"; import { - createContext, use, useDeferredValue, useEffect, @@ -43,10 +43,7 @@ import { type VerticalHinge, deriveWorkspacePaneLayout, isPrimarySidebarPinned, - type FileInspectorPaneLayout, - type Layout, type WorkspaceAuxiliaryPaneRole, - type WorkspacePaneLayout, } from "../../lib/layout"; import { resolveThreadSelectionNavigationAction, @@ -73,6 +70,7 @@ import { } from "../workspace/AndroidWorkspaceNavigation"; import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; +import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { newTaskDestination } from "../threads/new-task-project-selection"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; @@ -94,65 +92,7 @@ import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { AndroidHingeObserver } from "../../native/AndroidHingeObserver"; import { WindowHingeContext } from "../../native/window-hinge-context"; -interface AdaptiveWorkspaceContextValue { - readonly layout: Layout; - readonly panes: WorkspacePaneLayout; - readonly fileInspector: FileInspectorPaneLayout; - readonly primarySidebarSearchQuery: string; - readonly startNewTask: () => void; - readonly deferThreadSelectionUntil: (operation: Promise) => void; - readonly selectThread: (thread: EnvironmentThreadShell) => void; - readonly selectThreadRoute: (params: ReactNavigation.RootParamList["Thread"]) => void; - readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; - readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; - readonly setPrimarySidebarSearchQuery: (query: string) => void; - readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; - readonly toggleAuxiliaryPane: () => void; - readonly togglePrimarySidebar: () => void; - readonly setAuxiliaryPaneWidth: (width: number) => void; - /** - * Android split view while the thread composer has the soft keyboard: the - * sidebar and rail step aside and the thread header drops to one line. - */ - readonly composerKeyboardActive: boolean; - /** The thread composer reports its focus so the layout can make room for typing. */ - readonly setComposerEditing: (editing: boolean) => 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, - primarySidebarSearchQuery: "", - startNewTask: () => undefined, - deferThreadSelectionUntil: () => undefined, - selectThread: () => undefined, - selectThreadRoute: () => undefined, - activateAuxiliaryPaneRole: () => () => undefined, - registerWorkspaceInspector: () => () => undefined, - setPrimarySidebarSearchQuery: () => undefined, - showAuxiliaryPane: () => undefined, - toggleAuxiliaryPane: () => undefined, - togglePrimarySidebar: () => undefined, - setAuxiliaryPaneWidth: () => undefined, - composerKeyboardActive: false, - setComposerEditing: () => undefined, -}); - -export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { - return use(AdaptiveWorkspaceContext); -} +export { useAdaptiveWorkspaceLayout }; export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) { const { activateAuxiliaryPaneRole } = useAdaptiveWorkspaceLayout(); @@ -265,9 +205,11 @@ function AdaptiveWorkspaceLayoutContent( const nativeMetrics = useNativeLayoutMetrics(); const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; const pathname = props.pathname; - // Android's Settings tab is a full workspace page: the rail stays, the sidebar and - // auxiliary panes hide. - const workspacePage = Platform.OS === "android" && pathname.startsWith("/settings"); + const workspace = useWorkspaceTabs(); + // Settings and Usage occupy the workspace rather than a list/detail pair. + const workspacePage = + Platform.OS === "android" && + (pathname.startsWith("/settings") || workspace?.state.selectedTab === "usage"); const navigation = useNavigation(); const activeRoleOwner = useRef(null); const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); @@ -288,9 +230,27 @@ function AdaptiveWorkspaceLayoutContent( // re-renders on any layout state change, and the inspector registration then loops: each // re-register re-renders its screen, which hands over a new inspector renderer. const baseLayout = useMemo( - () => deriveLayout({ width: viewportWidth, height, nativeMetrics }), - [height, viewportWidth, nativeMetrics], + () => deriveLayout({ width: viewportWidth, height, nativeMetrics, nativeWorkspace }), + [height, viewportWidth, nativeMetrics, nativeWorkspace], ); + const [overlaySidebar, setOverlaySidebar] = useState({ + active: baseLayout.primarySidebarOverlaysContent, + pathname, + visible: pathname === "/", + }); + if ( + overlaySidebar.active !== baseLayout.primarySidebarOverlaysContent || + (overlaySidebar.active && overlaySidebar.pathname !== pathname) + ) { + setOverlaySidebar({ + active: baseLayout.primarySidebarOverlaysContent, + pathname, + visible: pathname === "/", + }); + } + const preferredSidebarVisible = baseLayout.primarySidebarOverlaysContent + ? overlaySidebar.visible + : primarySidebarPreferredVisible; const primarySidebarPinned = isPrimarySidebarPinned({ pathname, nativeWorkspace, @@ -308,7 +268,7 @@ function AdaptiveWorkspaceLayoutContent( ); const showPrimarySidebar = !workspacePage && - (primarySidebarPinned || (primarySidebarPreferredVisible && !composerKeyboardActive)); + (primarySidebarPinned || (preferredSidebarVisible && !composerKeyboardActive)); const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView; const navigationRailWidth = shouldRenderNavigationRail && (showPrimarySidebar || workspacePage) @@ -340,7 +300,7 @@ function AdaptiveWorkspaceLayoutContent( viewportWidth: width, preferredWidth: fileInspectorPreferredWidth ?? undefined, reservedLeadingWidth: - shouldRenderPrimarySidebar && showPrimarySidebar + shouldRenderPrimarySidebar && showPrimarySidebar && !layout.primarySidebarOverlaysContent ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, }), @@ -424,6 +384,13 @@ function AdaptiveWorkspaceLayoutContent( setFocusedAuxiliaryPaneRole(null); }; }; + const setPrimarySidebarVisible = (visible: boolean) => { + if (layout.primarySidebarOverlaysContent) { + setOverlaySidebar((current) => ({ ...current, visible })); + } else { + setPrimarySidebarPreferredVisible(visible); + } + }; const togglePrimarySidebar = () => { if (primarySidebarPinned) { return; @@ -439,14 +406,14 @@ function AdaptiveWorkspaceLayoutContent( setPrimarySidebarPreferredVisible(true); return; } - setPrimarySidebarPreferredVisible((current) => !current); + setPrimarySidebarVisible(!preferredSidebarVisible); }; const revealPrimarySidebar = () => { if (composerKeyboardActive) void KeyboardController.dismiss(); if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); } - setPrimarySidebarPreferredVisible(true); + setPrimarySidebarVisible(true); }; const handleToggleSidebarCommand = () => { togglePrimarySidebar(); @@ -803,6 +770,7 @@ function AdaptiveWorkspaceLayoutContent( showAuxiliaryPane, toggleAuxiliaryPane, togglePrimarySidebar, + setPrimarySidebarVisible, setAuxiliaryPaneWidth, composerKeyboardActive, setComposerEditing, diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx index d719c85525d1..d58360e7e99c 100644 --- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -13,6 +13,8 @@ export function WorkspaceEmptyDetail(props: { readonly onAddConnection?: () => void; }) { const pullRequests = props.tab === "pullRequests"; + if (props.tab && props.tab !== "threads" && props.tab !== "pullRequests") + return ; return ( void; + readonly deferThreadSelectionUntil: (operation: Promise) => void; + readonly selectThread: (thread: EnvironmentThreadShell) => void; + readonly selectThreadRoute: (params: ReactNavigation.RootParamList["Thread"]) => void; + readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; + readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; + readonly setPrimarySidebarSearchQuery: (query: string) => void; + readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void; + readonly toggleAuxiliaryPane: () => void; + readonly togglePrimarySidebar: () => void; + readonly setPrimarySidebarVisible: (visible: boolean) => void; + readonly setAuxiliaryPaneWidth: (width: number) => void; + /** + * Android split view while the thread composer has the soft keyboard: the + * sidebar and rail step aside and the thread header drops to one line. + */ + readonly composerKeyboardActive: boolean; + /** The thread composer reports its focus so the layout can make room for typing. */ + readonly setComposerEditing: (editing: boolean) => 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, +}); +export const AdaptiveWorkspaceContext = createContext({ + layout: compactLayout, + panes: compactPanes, + fileInspector: compactFileInspector, + primarySidebarSearchQuery: "", + startNewTask: () => undefined, + deferThreadSelectionUntil: () => undefined, + selectThread: () => undefined, + selectThreadRoute: () => undefined, + activateAuxiliaryPaneRole: () => () => undefined, + registerWorkspaceInspector: () => () => undefined, + setPrimarySidebarSearchQuery: () => undefined, + showAuxiliaryPane: () => undefined, + toggleAuxiliaryPane: () => undefined, + togglePrimarySidebar: () => undefined, + setPrimarySidebarVisible: () => undefined, + setAuxiliaryPaneWidth: () => undefined, + composerKeyboardActive: false, + setComposerEditing: () => undefined, +}); + +export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { + return use(AdaptiveWorkspaceContext); +} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 262e0ac71676..bfa5ebf03f11 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -26,7 +26,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { WorkspaceTabsProvider, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { WorkspaceNativeTabs } from "../workspace/WorkspaceNativeTabs.ios"; -import { isDuoDevice } from "../../lib/device-type"; +import { workspaceUsesColumns } from "../workspace/workspace-tabs-state"; import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; @@ -78,15 +78,17 @@ function ColumnScreen(props: { readonly navigation: ViewProps["navigation"]; }) { const { descriptor } = props; + const [presented, setPresented] = useState(false); const primaryColumn = useMemo( () => props.primary ? { + presented, selectedThreadKey: props.selectedThreadKey ?? null, selectedPullRequestKey: props.selectedPullRequestKey ?? null, } : null, - [props.primary, props.selectedThreadKey, props.selectedPullRequestKey], + [props.primary, props.selectedThreadKey, props.selectedPullRequestKey, presented], ); const key = descriptor.route.key; return ( @@ -101,10 +103,12 @@ function ColumnScreen(props: { props.navigation.emit({ type: "transitionStart", target: key, data: { closing: false } }); }} onDidAppear={() => { + setPresented(true); props.onAppearance("didAppear", key); props.navigation.emit({ type: "transitionEnd", target: key, data: { closing: false } }); }} onWillDisappear={() => { + setPresented(false); props.onAppearance("willDisappear", key); props.navigation.emit({ type: "transitionStart", target: key, data: { closing: true } }); }} @@ -145,8 +149,14 @@ function ColumnScreen(props: { function WorkspaceColumns( props: ViewProps & { readonly primary: Route; readonly detail: Route[] }, ) { - const { layout, panes, togglePrimarySidebar, toggleAuxiliaryPane, startNewTask } = - useAdaptiveWorkspaceLayout(); + const { + layout, + panes, + togglePrimarySidebar, + setPrimarySidebarVisible, + toggleAuxiliaryPane, + startNewTask, + } = useAdaptiveWorkspaceLayout(); const deviceType = useDeviceType(); // Split screens must remain mounted while UIKit animates a detached route away. const [screens, setScreens] = useState({ @@ -178,12 +188,10 @@ function WorkspaceColumns( const selectedTab = useWorkspaceTabs()?.state.selectedTab; const openNewTask = startNewTask; - // The Duo keeps compose in the detail column's bar, which is its vertical bar in landscape - // and folded, so it sits with the column's other actions whichever screen is shown. Pull - // requests leave it out, including one opened from a thread; their bar is already full of - // review actions. + // The cover detail rail needs room for thread tools above the fixed tabs. + // The unfolded Duo keeps compose in its detail bar. const detailCompose = - isDuoDevice(deviceType) && selectedTab !== "pullRequests" ? openNewTask : undefined; + deviceType === "duo-unfolded" && selectedTab !== "pullRequests" ? openNewTask : undefined; const inspector = use(NativeWorkspaceInspectorContext); const hostRef = useRef(null); const activeDetailKey = props.detail.at(-1)?.key; @@ -257,7 +265,8 @@ function WorkspaceColumns( { collapsed.current = true; @@ -271,9 +280,16 @@ function WorkspaceColumns( // collapsed split already carries the sidebar the expanded one will show. (layout.usesSplitView ? panes.primarySidebarVisible : panes.primarySidebarWhenExpanded) || (!layout.usesSplitView && !activeDetailKey) - ? "oneBesideSecondary" + ? layout.primarySidebarOverlaysContent + ? "oneOverSecondary" + : "oneBesideSecondary" : "secondaryOnly" } + onDisplayModeWillChange={({ nativeEvent }) => { + if (!layout.primarySidebarOverlaysContent) return; + if (nativeEvent.nextDisplayMode === "oneOverSecondary") setPrimarySidebarVisible(true); + else if (nativeEvent.nextDisplayMode === "secondaryOnly") setPrimarySidebarVisible(false); + }} displayModeButtonVisibility="never" presentsWithGesture showInspector={inspector.visible} @@ -362,7 +378,8 @@ function WorkspaceColumns( } function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean }) { - const { layout, startNewTask } = useAdaptiveWorkspaceLayout(); + const { layout } = useAdaptiveWorkspaceLayout(); + const workspace = useWorkspaceTabs()!; // Native dismiss commits the route pop after the transition. Restore the dock // when the primary screen starts appearing, including interactive back gestures. const [primaryVisible, setPrimaryVisible] = useState(false); @@ -380,6 +397,7 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean const descriptors = props.descriptors; const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" }; const visibleRoute = projection.detail.at(-1) ?? primaryRoute; + const workspacePage = !workspaceUsesColumns(workspace.state.selectedTab, visibleRoute.name); const baseDescriptor: Descriptor = { ...primaryDescriptor, navigation: props.descriptors[visibleRoute.key]!.navigation, @@ -389,8 +407,8 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean options: { headerShown: false }, render: () => (