Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 8d96ee1522 docs(app): state why pane sizing reads the root layout width 2026-09-30 16:23:03 +02:00
Mohamed Boudra aa50151fa1 fix(app): handle Android fold configuration in place
Android fold transitions change smallestScreenWidthDp. Without declaring that configuration change, MainActivity is recreated and the foreground History route is lost. Keep the activity mounted so the existing responsive runtime receives the new geometry.

(cherry picked from commit ad3394ea5e582119ab5e907f0e614fe54c535428)
2026-09-30 15:42:15 +02:00
Mohamed Boudra bf4979fd62 fix(app): adapt Android orientation and tabs to fold geometry
Use physical display size for orientation and rendered window width for pane layout. Keep the native tab row usable when text measurements do not arrive during a fold.

Co-authored-by: yz <43207690+yzim@users.noreply.github.com>
Co-authored-by: Sam Ng <hongsheng@gmail.com>
(cherry picked from commit 83f0a8219e0afee1f1e6b5084d6a37346b5c98ca)
2026-09-30 15:42:07 +02:00
26 changed files with 508 additions and 90 deletions
+11
View File
@@ -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",
+10 -1
View File
@@ -3,6 +3,7 @@ const path = require("node:path");
const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withAndroidFoldConfiguration = require("./plugins/with-android-fold-configuration");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
@@ -100,7 +101,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 +110,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,
},
@@ -142,6 +150,7 @@ export default {
plugins: [
"expo-router",
withPasteInput,
withAndroidFoldConfiguration,
withAndroidScroll,
[withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins,
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
# Run only on a task-owned foldable emulator with a connected Paseo debug app.
# Usage: native-fold-history-route.android.sh <adb-serial> <avd-name> <agent-device-session> <workspace-name>
serial=${1:?adb serial required}
avd_name=${2:?expected AVD name required}
session=${3:?agent-device session required}
workspace_name=${4:?workspace name required}
actual_avd=$(adb -s "$serial" shell getprop ro.boot.qemu.avd_name | tr -d '\r')
if [[ "$actual_avd" != "$avd_name" ]]; then
echo "Refusing device $serial: expected $avd_name, found $actual_avd" >&2
exit 2
fi
initial_state=$(adb -s "$serial" shell cmd device_state print-state | tr -d '\r')
if [[ "$initial_state" != "0" && "$initial_state" != "2" ]]; then
echo "Expected CLOSED=0 or OPENED=2; got $initial_state" >&2
exit 2
fi
trap 'adb -s "$serial" shell cmd device_state state "$initial_state" >/dev/null' EXIT
wait_for_history() {
local snapshot
for _ in {1..20}; do
snapshot=$(agent-device snapshot --session "$session" -i)
if rg -q '\[group\] "History"' <<<"$snapshot"; then
return 0
fi
sleep 0.5
done
echo "$snapshot" >&2
return 1
}
wait_for_compact_workspace() {
local snapshot
for _ in {1..20}; do
snapshot=$(agent-device snapshot --session "$session" -i)
if rg -q 'Unable to find node on an unmounted component|RootErrorBoundary' <<<"$snapshot"; then
echo "Workspace hit a React Native error after folding" >&2
echo "$snapshot" >&2
return 1
fi
if rg -q 'Switch tabs \(' <<<"$snapshot"; then
return 0
fi
sleep 0.5
done
echo "$snapshot" >&2
return 1
}
adb -s "$serial" shell cmd device_state state 2
sleep 3
agent-device press 'text="History"' --session "$session"
if ! wait_for_history; then
echo "History route did not open on the wide foldable" >&2
exit 1
fi
adb -s "$serial" shell cmd device_state state 0
sleep 4
if ! wait_for_history; then
echo "History route was lost after folding CLOSED" >&2
exit 1
fi
adb -s "$serial" shell cmd device_state state 2
sleep 4
if ! wait_for_history; then
echo "History route was lost after unfolding OPENED" >&2
exit 1
fi
agent-device press "label=\"$workspace_name\"" --session "$session"
adb -s "$serial" shell cmd device_state state 0
sleep 4
if ! wait_for_compact_workspace; then
echo "Compact workspace failed after returning from History" >&2
exit 1
fi
echo "History retained and compact workspace mounted after OPENED -> CLOSED -> OPENED -> CLOSED"
@@ -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 <serial> 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.
@@ -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
@@ -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"
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -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,
)
}
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["sh.paseo.screengeometry.PaseoScreenGeometryModule"]
}
}
@@ -0,0 +1,5 @@
{
"name": "paseo-screen-geometry",
"version": "0.1.0",
"private": true
}
+1
View File
@@ -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",
@@ -0,0 +1,21 @@
const { withAndroidManifest } = require("expo/config-plugins");
function withAndroidFoldConfiguration(config) {
return withAndroidManifest(config, (modConfig) => {
const activity = modConfig.modResults.manifest.application?.[0]?.activity?.find(
(entry) => entry.$?.["android:name"] === ".MainActivity",
);
if (!activity) {
throw new Error("Could not configure fold changes without MainActivity");
}
// A fold changes smallestScreenWidthDp as well as screenSize. Let React Native and
// Unistyles receive the configuration change without Android recreating MainActivity.
const changes = new Set((activity.$["android:configChanges"] ?? "").split("|").filter(Boolean));
changes.add("smallestScreenSize");
activity.$["android:configChanges"] = [...changes].join("|");
return modConfig;
});
}
module.exports = withAndroidFoldConfiguration;
@@ -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);
});
});
+23 -11
View File
@@ -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 <CommandCenterProvider>{content}</CommandCenterProvider>;
}
@@ -967,16 +972,23 @@ function RootProviders({ children }: { children: ReactNode }) {
}
function RootAppTree() {
const [measuredWindowWidth, setMeasuredWindowWidth] = useState<number | null>(null);
const handleRootLayout = useCallback((event: LayoutChangeEvent) => {
const width = event.nativeEvent.layout.width;
if (width > 0) setMeasuredWindowWidth((current) => (current === width ? current : width));
}, []);
return (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</View>
</GestureHandlerRootView>
<MeasuredWindowWidthProvider width={measuredWindowWidth}>
<GestureHandlerRootView style={flexStyle} onLayout={handleRootLayout}>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</View>
</GestureHandlerRootView>
</MeasuredWindowWidthProvider>
);
}
+2 -2
View File
@@ -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,
@@ -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);
});
});
@@ -0,0 +1,34 @@
import { createContext, type ReactNode, useContext } from "react";
import { useWindowDimensions } from "react-native";
const MeasuredWindowWidthContext = createContext<number | null>(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 (
<MeasuredWindowWidthContext.Provider value={width}>
{children}
</MeasuredWindowWidthContext.Provider>
);
}
/**
* Width of the rendered root view; RN Dimensions until the first layout.
* RN Dimensions settles after the root layout during an in-place Android fold.
* Sizing panes from Dimensions alone made the compact panel gesture fail to
* attach after folding closed, so pane sizing reads this width instead.
*/
export function useLayoutWindowWidth(): number {
const measuredWidth = useContext(MeasuredWindowWidthContext);
const { width: dimensionsWidth } = useWindowDimensions();
return resolveLayoutWindowWidth(measuredWidth, dimensionsWidth);
}
@@ -0,0 +1,51 @@
import * as ScreenOrientation from "expo-screen-orientation";
import { useEffect, useState } from "react";
import { Dimensions } from "react-native";
import { useLayoutWindowWidth } from "@/constants/window-width";
import { getAndroidPhysicalScreenSize } from "./screen-geometry";
import { resolveAndroidOrientationPolicy } from "./policy";
export function useAdaptiveOrientation(): boolean {
const layoutWindowWidth = useLayoutWindowWidth();
const [screen, setScreen] = useState(getAndroidPhysicalScreenSize);
const policy = resolveAndroidOrientationPolicy(screen);
const [ready, setReady] = useState(() => policy === "system");
useEffect(() => {
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(() => {
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;
}
+3
View File
@@ -0,0 +1,3 @@
export function useAdaptiveOrientation(): boolean {
return true;
}
@@ -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");
});
});
+10
View File
@@ -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";
}
@@ -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<PaseoScreenGeometryModule>(
"PaseoScreenGeometry",
).getPhysicalScreenSize();
}
@@ -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
@@ -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<string, WorkspaceTabLabelMeasurement>,
): 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<number>(0);
const [tabsScrollViewportWidth, setTabsScrollViewportWidth] = useState<number>(0);
const tabsScrollRef = useRef<ScrollView>(null);
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
const tabScrollBoundary = useHorizontalScrollBoundary();
const { onLayout: onTabBoundaryLayout } = tabScrollBoundary;
const [labelMeasurements, setLabelMeasurements] = useState(
() => new Map<string, WorkspaceTabLabelMeasurement>(),
);
const [trackSnapshot, setTrackSnapshot] = useState<WorkspaceTabTrackSnapshot | null>(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({
/>
<View style={styles.tabsScrollContainer}>
<Animated.ScrollView
ref={tabsScrollRef}
horizontal
scrollEnabled={layout.requiresHorizontalScrollFallback}
testID="workspace-tabs-scroll"
style={tabsScrollStyle}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
onLayout={tabScrollBoundary.onLayout}
onLayout={handleTabsScrollLayout}
onContentSizeChange={tabScrollBoundary.onContentSizeChange}
onScroll={tabScrollBoundary.onScroll}
scrollEventThrottle={16}
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
computeWorkspaceTabLayout,
retainWorkspaceTabMeasuredWidth,
scrollOffsetToRevealWorkspaceTab,
} from "@/screens/workspace/workspace-tab-layout";
const metrics = {
@@ -120,6 +121,33 @@ describe("computeWorkspaceTabLayout", () => {
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);
});
});
@@ -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;