diff --git a/package-lock.json b/package-lock.json index c396338f9b..7c8c78850a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21273,6 +21273,16 @@ "node": ">=10" } }, + "node_modules/expo-screen-orientation": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-9.0.9.tgz", + "integrity": "sha512-UTU745XoqBvQJkZ820GTfMuOxlkppYqaeQ+x0wdu44cyrZiZugqe+egFF2+zC+SaxgAltU5EuO8GIj9xSF+YMw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-server": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", @@ -36473,6 +36483,7 @@ "expo-localization": "~17.0.9", "expo-notifications": "^0.32.16", "expo-router": "~6.0.13", + "expo-screen-orientation": "~9.0.9", "expo-sharing": "^14.0.8", "expo-splash-screen": "~31.0.10", "expo-sqlite": "~16.0.10", diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 79d68249f2..c3fe69fd0b 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -100,7 +100,8 @@ export default { name: variant.name, slug: "voice-mobile", version: nativeReleaseVersion.appVersion, - orientation: "portrait", + // Android chooses its full window before the runtime phone/fold/tablet policy applies. + orientation: "default", icon: "./assets/images/icon.png", scheme: "paseo", userInterfaceStyle: "automatic", @@ -108,6 +109,12 @@ export default { ios: { supportsTablet: true, infoPlist: { + // Keep the existing iPhone restriction while the iPad orientation key + // generated by Expo continues to permit rotation. + UISupportedInterfaceOrientations: [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationPortraitUpsideDown", + ], NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.", ITSAppUsesNonExemptEncryption: false, }, diff --git a/packages/app/e2e/mobile/agent-device/native-fold-open-preconditions.md b/packages/app/e2e/mobile/agent-device/native-fold-open-preconditions.md new file mode 100644 index 0000000000..23e094cf08 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-fold-open-preconditions.md @@ -0,0 +1,5 @@ +# Android fold-open flow + +Use a task-owned foldable emulator with real `cmd device_state` CLOSED (0) and OPENED (2) states, a connected development app, and an unsent draft. The recorded run used `paseo-fold-p3a` (Pixel 9 Pro Fold, API 35). Set `DRAFT_TEXT` to the exact composer value. Run the flow after a CLOSED → OPENED transition and confirm `adb -s shell cmd device_state print-state` returns `2`. `workspace-tabs-scroll` and the Settings footer prove the wide shell is usable. A regular tablet can satisfy those UI assertions, so the device-state precondition is part of the test, not inferred from the flow. + +The `.ad` flow opens the app to establish an agent-device session. For the Gboard transition check, keep the app foreground, show Gboard with a real unsent draft, change device state with `cmd device_state state 0` then `state 2`, and capture the app before reopening it. An app-open step can recover a missed native layout callback and cannot prove that the immediate transition was correct. Retain before/after screenshots, a post-transition accessibility snapshot, `dumpsys activity` window bounds, and `dumpsys input_method` IME state. Repeat with the keyboard hidden, and verify CLOSED still presents the compact tab switcher. diff --git a/packages/app/e2e/mobile/agent-device/native-fold-open.android.ad b/packages/app/e2e/mobile/agent-device/native-fold-open.android.ad new file mode 100644 index 0000000000..4058e9ac08 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-fold-open.android.ad @@ -0,0 +1,8 @@ +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-tabs-scroll\"" 12000 +wait "label=\"${DRAFT_TEXT}\"" 10000 +wait "label=\"Settings\"" 10000 +close diff --git a/packages/app/modules/paseo-screen-geometry/android/build.gradle b/packages/app/modules/paseo-screen-geometry/android/build.gradle new file mode 100644 index 0000000000..69ff84a6e9 --- /dev/null +++ b/packages/app/modules/paseo-screen-geometry/android/build.gradle @@ -0,0 +1,15 @@ +apply plugin: 'com.android.library' + +group = 'sh.paseo.screengeometry' +version = '0.1.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() +useDefaultAndroidSdkVersions() + +android { + namespace "sh.paseo.screengeometry" +} diff --git a/packages/app/modules/paseo-screen-geometry/android/src/main/AndroidManifest.xml b/packages/app/modules/paseo-screen-geometry/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..94cbbcfc39 --- /dev/null +++ b/packages/app/modules/paseo-screen-geometry/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/app/modules/paseo-screen-geometry/android/src/main/java/sh/paseo/screengeometry/PaseoScreenGeometryModule.kt b/packages/app/modules/paseo-screen-geometry/android/src/main/java/sh/paseo/screengeometry/PaseoScreenGeometryModule.kt new file mode 100644 index 0000000000..b085ba2366 --- /dev/null +++ b/packages/app/modules/paseo-screen-geometry/android/src/main/java/sh/paseo/screengeometry/PaseoScreenGeometryModule.kt @@ -0,0 +1,28 @@ +package sh.paseo.screengeometry + +import android.os.Build +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class PaseoScreenGeometryModule : Module() { + override fun definition() = ModuleDefinition { + Name("PaseoScreenGeometry") + + Function("getPhysicalScreenSize") { + val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity() + val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + activity.display + } else { + @Suppress("DEPRECATION") + activity.windowManager.defaultDisplay + } + val mode = display.mode + val density = activity.resources.displayMetrics.density + mapOf( + "width" to mode.physicalWidth / density, + "height" to mode.physicalHeight / density, + ) + } + } +} diff --git a/packages/app/modules/paseo-screen-geometry/expo-module.config.json b/packages/app/modules/paseo-screen-geometry/expo-module.config.json new file mode 100644 index 0000000000..e5de86dbbb --- /dev/null +++ b/packages/app/modules/paseo-screen-geometry/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["sh.paseo.screengeometry.PaseoScreenGeometryModule"] + } +} diff --git a/packages/app/modules/paseo-screen-geometry/package.json b/packages/app/modules/paseo-screen-geometry/package.json new file mode 100644 index 0000000000..8191be4fd6 --- /dev/null +++ b/packages/app/modules/paseo-screen-geometry/package.json @@ -0,0 +1,5 @@ +{ + "name": "paseo-screen-geometry", + "version": "0.1.0", + "private": true +} diff --git a/packages/app/package.json b/packages/app/package.json index 1a293d00a0..26b84c1ede 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -95,6 +95,7 @@ "expo-localization": "~17.0.9", "expo-notifications": "^0.32.16", "expo-router": "~6.0.13", + "expo-screen-orientation": "~9.0.9", "expo-sharing": "^14.0.8", "expo-splash-screen": "~31.0.10", "expo-sqlite": "~16.0.10", diff --git a/packages/app/src/android-orientation-config.test.ts b/packages/app/src/android-orientation-config.test.ts new file mode 100644 index 0000000000..1b0e05bea4 --- /dev/null +++ b/packages/app/src/android-orientation-config.test.ts @@ -0,0 +1,19 @@ +import { getConfig } from "@expo/config"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("native orientation configuration", () => { + const { exp } = getConfig(path.resolve(import.meta.dirname, "..")); + + it("lets Android choose the full display window before runtime orientation policy applies", () => { + expect(exp.orientation).toBe("default"); + }); + + it("keeps the iPhone portrait orientations explicit while iPad supports rotation", () => { + expect(exp.ios?.infoPlist?.UISupportedInterfaceOrientations).toEqual([ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationPortraitUpsideDown", + ]); + expect(exp.ios?.supportsTablet).toBe(true); + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index cf682e7611..e1d0cef9b3 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -15,7 +15,7 @@ import { useState, useSyncExternalStore, } from "react"; -import { AppState, useWindowDimensions, View } from "react-native"; +import { AppState, View, type LayoutChangeEvent } from "react-native"; import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -50,6 +50,7 @@ import { HEADER_INNER_HEIGHT, useIsCompactFormFactor, } from "@/constants/layout"; +import { MeasuredWindowWidthProvider, useLayoutWindowWidth } from "@/constants/window-width"; import { canDesktopAppSidebarShare, resolveDesktopAppChromeLayout, @@ -87,6 +88,7 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac import { useLatchedBoolean } from "@/hooks/use-latched-boolean"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { useAdaptiveOrientation } from "@/orientation"; import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar"; import { KeyboardShiftProvider } from "@/keyboard/shift"; import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock"; @@ -457,6 +459,7 @@ interface AppContainerProps { const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12; function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) { + const isOrientationReady = useAdaptiveOrientation(); const keyboardActionDispatcher = useKeyboardActionDispatcher(); const daemons = useHosts(); const { settings, updateSettings } = useAppSettings(); @@ -466,7 +469,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); - const { width: viewportWidth } = useWindowDimensions(); + const viewportWidth = useLayoutWindowWidth(); const cycleTheme = useCallback(() => { void updateSettings({ theme: getNextThemePreference(settings.theme) }); @@ -622,6 +625,8 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon surface ); + if (!isOrientationReady) return null; + return {content}; } @@ -967,16 +972,23 @@ function RootProviders({ children }: { children: ReactNode }) { } function RootAppTree() { + const [measuredWindowWidth, setMeasuredWindowWidth] = useState(null); + const handleRootLayout = useCallback((event: LayoutChangeEvent) => { + const width = event.nativeEvent.layout.width; + if (width > 0) setMeasuredWindowWidth((current) => (current === width ? current : width)); + }, []); return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index b1de36c8ea..cc969cd408 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -6,7 +6,6 @@ import { Pressable, StyleSheet as RNStyleSheet, Text, - useWindowDimensions, View, type PressableStateCallbackType, } from "react-native"; @@ -29,6 +28,7 @@ import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; +import { useLayoutWindowWidth } from "@/constants/window-width"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useImportSession } from "@/hooks/use-import-session"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; @@ -678,7 +678,7 @@ function DesktopSidebar({ const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); - const { width: viewportWidth } = useWindowDimensions(); + const viewportWidth = useLayoutWindowWidth(); const visibleSidebarWidth = resolveDesktopSidebarWidth({ requestedWidth: sidebarWidth, viewportWidth, diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 721708715c..b47572e148 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -1,5 +1,6 @@ -import { useUnistyles } from "react-native-unistyles"; import { isWeb } from "@/constants/platform"; +import { useLayoutWindowWidth } from "@/constants/window-width"; +import { isCompactWindowWidth } from "@/styles/breakpoints"; export const FOOTER_HEIGHT = 75; @@ -34,12 +35,11 @@ export { } from "./platform"; /** - * Reactive hook — re-renders the component when the breakpoint changes. - * Always use this instead of reading UnistylesRuntime.breakpoint directly. + * The rendered root width owns pane layout. Android fold transitions can + * leave both RN Dimensions and Unistyles' runtime breakpoint at the old size. */ export function useIsCompactFormFactor(): boolean { - const { rt } = useUnistyles(); - return rt.breakpoint === "xs" || rt.breakpoint === "sm"; + return isCompactWindowWidth(useLayoutWindowWidth()); } // SplitContainer relies on dnd-kit and DOM-backed accessibility helpers. diff --git a/packages/app/src/constants/window-width.test.ts b/packages/app/src/constants/window-width.test.ts new file mode 100644 index 0000000000..3ef4cf297e --- /dev/null +++ b/packages/app/src/constants/window-width.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { resolveLayoutWindowWidth } from "./window-width"; + +describe("resolveLayoutWindowWidth", () => { + it("uses the rendered root after a fold when RN Dimensions still reports the closed width", () => { + expect(resolveLayoutWindowWidth(851.7, 443.1)).toBe(851.7); + expect(resolveLayoutWindowWidth(443.1, 851.7)).toBe(443.1); + }); + + it("uses RN's initial width until the root has laid out", () => { + expect(resolveLayoutWindowWidth(null, 411)).toBe(411); + expect(resolveLayoutWindowWidth(0, 411)).toBe(411); + }); +}); diff --git a/packages/app/src/constants/window-width.tsx b/packages/app/src/constants/window-width.tsx new file mode 100644 index 0000000000..9aabbb407e --- /dev/null +++ b/packages/app/src/constants/window-width.tsx @@ -0,0 +1,29 @@ +import { createContext, type ReactNode, useContext } from "react"; +import { useWindowDimensions } from "react-native"; + +const MeasuredWindowWidthContext = createContext(null); + +export function resolveLayoutWindowWidth(measuredWidth: number | null, dimensionsWidth: number) { + return measuredWidth !== null && measuredWidth > 0 ? measuredWidth : dimensionsWidth; +} + +export function MeasuredWindowWidthProvider({ + width, + children, +}: { + width: number | null; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** The root view's actual width survives fold changes that skip RN Dimensions updates. */ +export function useLayoutWindowWidth(): number { + const measuredWidth = useContext(MeasuredWindowWidthContext); + const { width: dimensionsWidth } = useWindowDimensions(); + return resolveLayoutWindowWidth(measuredWidth, dimensionsWidth); +} diff --git a/packages/app/src/orientation/index.native.ts b/packages/app/src/orientation/index.native.ts new file mode 100644 index 0000000000..6ed0e89aa6 --- /dev/null +++ b/packages/app/src/orientation/index.native.ts @@ -0,0 +1,58 @@ +import * as ScreenOrientation from "expo-screen-orientation"; +import { useEffect, useState } from "react"; +import { Dimensions, Platform } from "react-native"; +import { useLayoutWindowWidth } from "@/constants/window-width"; +import { getAndroidPhysicalScreenSize } from "./screen-geometry"; +import { resolveAndroidOrientationPolicy } from "./policy"; + +const isAndroid = Platform.OS === "android"; + +export function useAdaptiveOrientation(): boolean { + const layoutWindowWidth = useLayoutWindowWidth(); + const [screen, setScreen] = useState(() => + isAndroid ? getAndroidPhysicalScreenSize() : Dimensions.get("screen"), + ); + const policy = resolveAndroidOrientationPolicy(screen); + const [ready, setReady] = useState(() => !isAndroid || policy === "system"); + + useEffect(() => { + if (!isAndroid) return; + const updateScreen = () => { + const nextScreen = getAndroidPhysicalScreenSize(); + setScreen((current) => + current.width === nextScreen.width && current.height === nextScreen.height + ? current + : nextScreen, + ); + }; + updateScreen(); + const subscription = Dimensions.addEventListener("change", updateScreen); + return () => subscription.remove(); + }, [layoutWindowWidth]); + + useEffect(() => { + if (!isAndroid) return; + + let active = true; + const request = + policy === "portrait" + ? ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP) + : ScreenOrientation.unlockAsync(); + + void request + .catch((error) => { + console.warn("[Orientation] Could not apply Android screen orientation:", error); + }) + .finally(() => { + if (active) setReady(true); + }); + + return () => { + active = false; + }; + }, [policy]); + + // An unrestricted native cold start can briefly report a landscape phone + // window; wait for the portrait request before mounting layout content. + return ready; +} diff --git a/packages/app/src/orientation/index.ts b/packages/app/src/orientation/index.ts new file mode 100644 index 0000000000..60d3b47ab8 --- /dev/null +++ b/packages/app/src/orientation/index.ts @@ -0,0 +1,3 @@ +export function useAdaptiveOrientation(): boolean { + return true; +} diff --git a/packages/app/src/orientation/policy.test.ts b/packages/app/src/orientation/policy.test.ts new file mode 100644 index 0000000000..2baa63034c --- /dev/null +++ b/packages/app/src/orientation/policy.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { resolveAndroidOrientationPolicy } from "./policy"; + +describe("Android orientation policy", () => { + it("allows a landscape tablet display at the API 35 letterbox baseline", () => { + expect(resolveAndroidOrientationPolicy({ width: 1280, height: 800 })).toBe("system"); + }); + + it("changes with a real fold display transition in either direction", () => { + const unfolded = { width: 852, height: 883 }; + const folded = { width: 443, height: 994 }; + expect(resolveAndroidOrientationPolicy(unfolded)).toBe("system"); + expect(resolveAndroidOrientationPolicy(folded)).toBe("portrait"); + expect(resolveAndroidOrientationPolicy(unfolded)).toBe("system"); + }); + + it("keeps a phone portrait when physically turned landscape", () => { + expect(resolveAndroidOrientationPolicy({ width: 914, height: 411 })).toBe("portrait"); + }); + + it("uses the 600dp shortest-side threshold regardless of rotation", () => { + expect(resolveAndroidOrientationPolicy({ width: 600, height: 1000 })).toBe("system"); + expect(resolveAndroidOrientationPolicy({ width: 1000, height: 599 })).toBe("portrait"); + }); +}); diff --git a/packages/app/src/orientation/policy.ts b/packages/app/src/orientation/policy.ts new file mode 100644 index 0000000000..78d38acb6c --- /dev/null +++ b/packages/app/src/orientation/policy.ts @@ -0,0 +1,10 @@ +export type AndroidOrientationPolicy = "portrait" | "system"; + +// Screen size owns the Android orientation allowance. Pane layout still uses +// the available window width, including in split and floating windows. +export function resolveAndroidOrientationPolicy(screen: { + width: number; + height: number; +}): AndroidOrientationPolicy { + return Math.min(screen.width, screen.height) >= 600 ? "system" : "portrait"; +} diff --git a/packages/app/src/orientation/screen-geometry.ts b/packages/app/src/orientation/screen-geometry.ts new file mode 100644 index 0000000000..0fafd5f315 --- /dev/null +++ b/packages/app/src/orientation/screen-geometry.ts @@ -0,0 +1,16 @@ +import { requireNativeModule } from "expo-modules-core"; + +interface AndroidPhysicalScreenSize { + width: number; + height: number; +} + +interface PaseoScreenGeometryModule { + getPhysicalScreenSize(): AndroidPhysicalScreenSize; +} + +export function getAndroidPhysicalScreenSize(): AndroidPhysicalScreenSize { + return requireNativeModule( + "PaseoScreenGeometry", + ).getPhysicalScreenSize(); +} diff --git a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts index 409d957abd..86db03be1b 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useWindowDimensions } from "react-native"; +import { useLayoutWindowWidth } from "@/constants/window-width"; import { computeWorkspaceTabLayout, type WorkspaceTabLayoutMetrics, @@ -7,7 +7,7 @@ import { } from "@/screens/workspace/workspace-tab-layout"; interface UseWorkspaceTabLayoutInput { - tabLabelWidths: number[]; + tabLabelWidths: (number | null)[]; viewportWidthOverride?: number | null; metrics: WorkspaceTabLayoutMetrics; } @@ -19,7 +19,7 @@ interface UseWorkspaceTabLayoutResult { export function useWorkspaceTabLayout( input: UseWorkspaceTabLayoutInput, ): UseWorkspaceTabLayoutResult { - const { width: viewportWidth } = useWindowDimensions(); + const viewportWidth = useLayoutWindowWidth(); const resolvedViewportWidth = typeof input.viewportWidthOverride === "number" && input.viewportWidthOverride > 0 ? input.viewportWidthOverride diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index bbb0561130..2835152526 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -9,7 +9,7 @@ import React, { type Dispatch, type SetStateAction, } from "react"; -import { Pressable, Text, View, type LayoutChangeEvent } from "react-native"; +import { Pressable, Text, View, type LayoutChangeEvent, type ScrollView } from "react-native"; import { CopyX, ArrowLeftToLine, @@ -49,7 +49,10 @@ import { buttonControlHeight } from "@/components/ui/control-geometry"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { Shortcut } from "@/components/ui/shortcut"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; -import { retainWorkspaceTabMeasuredWidth } from "@/screens/workspace/workspace-tab-layout"; +import { + retainWorkspaceTabMeasuredWidth, + scrollOffsetToRevealWorkspaceTab, +} from "@/screens/workspace/workspace-tab-layout"; import { WorkspaceTabPresentationResolver, WorkspaceTabIcon, @@ -459,39 +462,25 @@ interface WorkspaceTabLabelMeasurement { width: number; } -interface WorkspaceTabTrackSnapshot { - signature: string; - tabs: ResolvedWorkspaceDesktopTabRowItem[]; - labels: WorkspaceTabLabel[]; - labelWidths: number[]; -} - -function workspaceTabLabelSignature(labels: WorkspaceTabLabel[]): string { - return JSON.stringify(labels); -} - -function completeWorkspaceTabLabelWidths( +function resolveWorkspaceTabLabelWidths( labels: WorkspaceTabLabel[], measurements: Map, -): number[] | null { - const widths: number[] = []; +): (number | null)[] { + const widths: (number | null)[] = []; for (const { key, label, modified } of labels) { const measurement = measurements.get(key); - if (!measurement || measurement.label !== label || measurement.width <= 0) { - return null; - } + const labelWidth = + measurement?.label === label && measurement.width > 0 ? measurement.width : null; // The modified dot sits in the content row, so a modified tab needs that much more width // before its label starts truncating. const modifiedAllowance = modified ? TAB_CONTENT_GAP + TAB_MODIFIED_DOT_SIZE : 0; - widths.push(measurement.width + TAB_LABEL_LAYOUT_ALLOWANCE + modifiedAllowance); + widths.push( + labelWidth === null ? null : labelWidth + TAB_LABEL_LAYOUT_ALLOWANCE + modifiedAllowance, + ); } return widths; } -function sameWidths(left: number[], right: number[]): boolean { - return left.length === right.length && left.every((width, index) => width === right[index]); -} - interface WorkspaceDesktopTabsRowProps { paneId?: string; isFocused?: boolean; @@ -536,8 +525,6 @@ interface WorkspaceDesktopTabPresentationSlotProps { onResolve: (tabKey: string, presentation: WorkspaceTabPresentation) => void; } -const EMPTY_RESOLVED_TAB_ROWS: ResolvedWorkspaceDesktopTabRowItem[] = []; - function WorkspaceDesktopTabPresentationSlot({ tab, serverId, @@ -1022,12 +1009,14 @@ function ResolvedWorkspaceDesktopTabsRow({ const { t } = useTranslation(); const newTabKeys = useShortcutKeys("workspace-tab-new"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); + const [tabsScrollViewportWidth, setTabsScrollViewportWidth] = useState(0); + const tabsScrollRef = useRef(null); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); const tabScrollBoundary = useHorizontalScrollBoundary(); + const { onLayout: onTabBoundaryLayout } = tabScrollBoundary; const [labelMeasurements, setLabelMeasurements] = useState( () => new Map(), ); - const [trackSnapshot, setTrackSnapshot] = useState(null); const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { updateMeasuredWidth(setTabsContainerWidth, event); @@ -1101,7 +1090,6 @@ function ResolvedWorkspaceDesktopTabsRow({ }), [fallbackTabLabels, tabs], ); - const tabLabelSignature = useMemo(() => workspaceTabLabelSignature(tabLabels), [tabLabels]); const currentTabLabelKeys = useMemo(() => new Set(tabLabels.map(({ key }) => key)), [tabLabels]); useEffect(() => { setLabelMeasurements((current) => { @@ -1116,35 +1104,6 @@ function ResolvedWorkspaceDesktopTabsRow({ return next; }); }, [currentTabLabelKeys]); - const publishMeasuredTrack = useCallback(() => { - if (tabsContainerWidth <= 0) { - return; - } - const labelWidths = completeWorkspaceTabLabelWidths(tabLabels, labelMeasurements); - if (!labelWidths) { - return; - } - - setTrackSnapshot((current) => { - if ( - current?.signature === tabLabelSignature && - sameWidths(current.labelWidths, labelWidths) - ) { - return current; - } - return { - signature: tabLabelSignature, - tabs, - labels: tabLabels, - labelWidths, - }; - }); - }, [labelMeasurements, tabLabelSignature, tabLabels, tabs, tabsContainerWidth]); - - useLayoutEffect(() => { - publishMeasuredTrack(); - }, [publishMeasuredTrack]); - const handleTabLabelLayout = useCallback( (key: string, label: string, event: LayoutChangeEvent) => { const width = Math.ceil(event.nativeEvent.layout.width); @@ -1164,25 +1123,46 @@ function ResolvedWorkspaceDesktopTabsRow({ [], ); - const displayedTabs = useMemo(() => { - if (!trackSnapshot) { - return EMPTY_RESOLVED_TAB_ROWS; - } - const currentTabs = new Map( - tabs.map((tab, index) => [tab.tab.key, { tab, label: tabLabels[index]?.label }]), - ); - return trackSnapshot.tabs.map((snapshotTab, index) => { - const current = currentTabs.get(snapshotTab.tab.key); - return current?.label === trackSnapshot.labels[index]?.label ? current.tab : snapshotTab; - }); - }, [tabLabels, tabs, trackSnapshot]); + const tabLabelWidths = useMemo( + () => resolveWorkspaceTabLabelWidths(tabLabels, labelMeasurements), + [tabLabels, labelMeasurements], + ); + const displayedTabs = tabs; const { layout } = useWorkspaceTabLayout({ - tabLabelWidths: trackSnapshot?.labelWidths ?? [], + tabLabelWidths, viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, metrics: layoutMetrics, }); + const selectedTabIndex = displayedTabs.findIndex((item) => item.isActive); + const selectedTabKey = selectedTabIndex >= 0 ? displayedTabs[selectedTabIndex]?.tab.key : null; + useEffect(() => { + if (!layout.requiresHorizontalScrollFallback || selectedTabKey === null) return; + const offset = scrollOffsetToRevealWorkspaceTab( + layout.items.map((item) => item.width), + selectedTabIndex, + tabsScrollViewportWidth, + TAB_CHIP_GAP, + TAB_ROW_PADDING_HORIZONTAL, + ); + tabsScrollRef.current?.scrollTo({ x: offset, animated: true }); + }, [ + layout.items, + layout.requiresHorizontalScrollFallback, + selectedTabIndex, + selectedTabKey, + tabsScrollViewportWidth, + ]); + + const handleTabsScrollLayout = useCallback( + (event: LayoutChangeEvent) => { + onTabBoundaryLayout(event); + updateMeasuredWidth(setTabsScrollViewportWidth, event); + }, + [onTabBoundaryLayout], + ); + const handleDragEnd = useCallback( (nextTabs: ResolvedWorkspaceDesktopTabRowItem[]) => { onReorderTabs(nextTabs.map((tab) => tab.tab)); @@ -1336,13 +1316,14 @@ function ResolvedWorkspaceDesktopTabsRow({ /> { expect(result.items.map((item) => item.width)).toEqual([102, 138]); }); + + it("keeps unmeasured tabs and controls accessible until text layout arrives", () => { + const initial = computeWorkspaceTabLayout({ + viewportWidth: 440, + tabLabelWidths: [null, null, null], + metrics, + }); + expect(initial.items.map((item) => item.width)).toEqual([96, 96, 96]); + expect(initial.closeButtonPolicy).toBe("all"); + expect(initial.requiresHorizontalScrollFallback).toBe(false); + + const measured = computeWorkspaceTabLayout({ + viewportWidth: 440, + tabLabelWidths: [40, 130, 90], + metrics, + }); + expect(measured.items).toHaveLength(3); + expect(measured.items[1]?.width).toBeGreaterThan(initial.items[1]?.width ?? 0); + + const overflowing = computeWorkspaceTabLayout({ + viewportWidth: 440, + tabLabelWidths: [null, null, null, null], + metrics, + }); + expect(overflowing.requiresHorizontalScrollFallback).toBe(true); + expect(overflowing.items).toHaveLength(4); + }); }); describe("retainWorkspaceTabMeasuredWidth", () => { @@ -131,3 +159,15 @@ describe("retainWorkspaceTabMeasuredWidth", () => { expect(retainWorkspaceTabMeasuredWidth(720, 640)).toBe(640); }); }); + +describe("scrollOffsetToRevealWorkspaceTab", () => { + it("reveals the selected tab after a wide row becomes an overflowing row", () => { + const widths = Array.from({ length: 6 }, () => 96); + expect(scrollOffsetToRevealWorkspaceTab(widths, 5, 482, 4, 4)).toBe(122); + expect(scrollOffsetToRevealWorkspaceTab(widths, 0, 482, 4, 4)).toBe(0); + }); + + it("keeps a selected tab in place when it already fits", () => { + expect(scrollOffsetToRevealWorkspaceTab([96, 160, 96], 1, 480, 4, 4)).toBe(0); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index 2693e24cd7..94f179af85 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -15,7 +15,7 @@ export interface WorkspaceTabLayoutMetrics { export interface WorkspaceTabLayoutInput { viewportWidth: number; - tabLabelWidths: number[]; + tabLabelWidths: (number | null)[]; metrics: WorkspaceTabLayoutMetrics; } @@ -40,6 +40,21 @@ export function retainWorkspaceTabMeasuredWidth( return measuredWidth; } +export function scrollOffsetToRevealWorkspaceTab( + widths: readonly number[], + selectedIndex: number, + viewportWidth: number, + gap: number, + padding: number, +): number { + if (selectedIndex < 0 || selectedIndex >= widths.length || viewportWidth <= 0) return 0; + const end = + padding + + widths.slice(0, selectedIndex + 1).reduce((total, width) => total + width, 0) + + gap * selectedIndex; + return Math.max(0, end + padding - viewportWidth); +} + function clamp(value: number, min: number, max: number): number { if (value < min) { return min; @@ -74,8 +89,13 @@ export function computeWorkspaceTabLayout( input.metrics.tabContentGap + input.metrics.tabHorizontalPadding * 2 + input.metrics.closeButtonWidth; + // An unmeasured label starts at the chip's known minimum. The row stays + // interactive if native skips the hidden Text onLayout during a fold, and + // exact measurements take over whenever they arrive. const naturalWidths = input.tabLabelWidths.map((labelWidth) => - clamp(tabChromeWidth + labelWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth), + labelWidth === null + ? input.metrics.minTabWidth + : clamp(tabChromeWidth + labelWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth), ); const naturalTotalWidth = naturalWidths.reduce((total, width) => total + width, 0); const minimumTotalWidth = input.metrics.minTabWidth * tabCount; diff --git a/packages/app/src/styles/breakpoints.test.ts b/packages/app/src/styles/breakpoints.test.ts new file mode 100644 index 0000000000..e7b849ccbc --- /dev/null +++ b/packages/app/src/styles/breakpoints.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { BREAKPOINTS, isCompactWindowWidth } from "./breakpoints"; + +describe("pane layout width", () => { + it("keeps a narrow window compact on a large physical display", () => { + expect(isCompactWindowWidth(443)).toBe(true); + }); + + it("switches at the same medium breakpoint used by styles", () => { + expect(isCompactWindowWidth(BREAKPOINTS.md - 1)).toBe(true); + expect(isCompactWindowWidth(BREAKPOINTS.md)).toBe(false); + expect(isCompactWindowWidth(852)).toBe(false); + }); +}); diff --git a/packages/app/src/styles/breakpoints.ts b/packages/app/src/styles/breakpoints.ts new file mode 100644 index 0000000000..93f7cc22c6 --- /dev/null +++ b/packages/app/src/styles/breakpoints.ts @@ -0,0 +1,11 @@ +export const BREAKPOINTS = { + xs: 0, + sm: 576, + md: 720, + lg: 992, + xl: 1200, +} as const; + +export function isCompactWindowWidth(width: number): boolean { + return width < BREAKPOINTS.md; +} diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index b679d5e694..fecb3624dc 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -1,15 +1,10 @@ import { StyleSheet } from "react-native-unistyles"; +import { BREAKPOINTS } from "./breakpoints"; import { REGISTERED_THEMES } from "./theme"; StyleSheet.configure({ themes: REGISTERED_THEMES, - breakpoints: { - xs: 0, - sm: 576, - md: 720, - lg: 992, - xl: 1200, - }, + breakpoints: BREAKPOINTS, settings: { adaptiveThemes: true, },