From 41efc71a150e19ad46ab19db64649300f1b6ab0a Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:00:46 -0700 Subject: [PATCH] feat(mobile): foldable/tablet split-view sidebar UX for Android Restores split view for near-square foldable windows and adds Android sidebar controls so the thread list can be hidden, shown, and swiped on devices like the Galaxy Z Fold 7. - Sidebar visibility and animation drive hide/show across fold changes. - Android split headers draw in-flow instead of relying on the native stack header, fixing duplicate brand/back headers that lingered across fold/unfold. - Left-edge-gated pan swipes the sidebar away and back in, with header buttons as the accessible fallback. - New-task FAB sits in the persistent Android split sidebar. - Split-detection and pane-visibility behavior are covered by layout and swipe unit tests. Verified on a Galaxy Z Fold 7. --- apps/mobile/src/Stack.tsx | 30 ++++- .../src/components/AndroidScreenHeader.tsx | 32 ++++- .../src/components/CompactBrandTitle.tsx | 3 + apps/mobile/src/components/LoadingScreen.tsx | 12 +- .../features/files/ThreadFilesRouteScreen.tsx | 41 +++++- .../src/features/home/HomeRouteScreen.tsx | 31 +++-- .../layout/AdaptiveWorkspaceLayout.tsx | 124 ++++++++++++++---- .../features/layout/WorkspaceEmptyDetail.tsx | 22 +++- .../layout/workspace-sidebar-swipe.test.ts | 61 +++++++++ .../layout/workspace-sidebar-swipe.ts | 27 ++++ .../src/features/review/ReviewSheet.tsx | 18 ++- .../terminal/ThreadTerminalRouteScreen.tsx | 86 ++++++++---- .../threads/ThreadNavigationSidebar.tsx | 76 +++-------- .../features/threads/ThreadRouteScreen.tsx | 61 ++++++--- .../sidebar-header-actions.android.tsx | 16 --- apps/mobile/src/lib/layout.test.ts | 58 +++++++- 16 files changed, 528 insertions(+), 170 deletions(-) create mode 100644 apps/mobile/src/features/layout/workspace-sidebar-swipe.test.ts create mode 100644 apps/mobile/src/features/layout/workspace-sidebar-swipe.ts delete mode 100644 apps/mobile/src/features/threads/sidebar-header-actions.android.tsx diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index da1be88a8bdb..fc5a8f2e6777 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -407,6 +407,12 @@ export const RootStack = createNativeStackNavigator({ linking: "", options: { ...GLASS_HEADER_OPTIONS, + // Android draws its own in-flow header (AndroidHomeHeader in compact, + // the sidebar brand + empty detail in split), so the native stack + // header must never mount there — runtime `headerShown` toggling does + // not reliably remove an already-shown native header and would leave + // a duplicate brand header in the split main pane. + headerShown: Platform.OS !== "android", contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, ...getCompactBrandHeaderOptions(), @@ -415,17 +421,31 @@ export const RootStack = createNativeStackNavigator({ Thread: createNativeStackScreen({ screen: ThreadRouteScreen, linking: THREAD_LINKING_PREFIX, - options: GLASS_HEADER_OPTIONS, + options: { + ...GLASS_HEADER_OPTIONS, + // Android draws its own in-flow header (AndroidScreenHeader in + // ThreadRouteScreen); the native stack header stays iOS-only. Keeping + // it disabled statically avoids a stale native header surviving a + // fold/unfold layout change (runtime headerShown toggling cannot + // reliably unmount an already-shown native header). + headerShown: Platform.OS !== "android", + }, }), ThreadTerminal: createNativeStackScreen({ screen: ThreadTerminalRouteScreen, linking: `${THREAD_LINKING_PREFIX}/terminal`, - options: SOLID_HEADER_OPTIONS, + options: { + ...SOLID_HEADER_OPTIONS, + headerShown: Platform.OS !== "android", + }, }), ThreadReview: createNativeStackScreen({ screen: ReviewSheet, linking: `${THREAD_LINKING_PREFIX}/review`, - options: SOLID_HEADER_OPTIONS, + options: { + ...SOLID_HEADER_OPTIONS, + headerShown: Platform.OS !== "android", + }, }), ThreadReviewComment: createNativeStackScreen({ screen: ReviewCommentComposerSheet, @@ -443,6 +463,10 @@ export const RootStack = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/files`, options: { ...GLASS_HEADER_OPTIONS, + // Android draws its own in-flow header (AndroidScreenHeader in + // ThreadFilesTreeScreen); keep the native header iOS-only statically + // so it cannot survive a fold/unfold layout change. + headerShown: Platform.OS !== "android", contentStyle: SHEET_BACKGROUND_COLOR !== undefined ? { backgroundColor: SHEET_BACKGROUND_COLOR } diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 7fe21fb44ff3..8326f7c9bfb3 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { Pressable, View } from "react-native"; +import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView, type AppSymbolName } from "./AppSymbol"; @@ -63,14 +63,15 @@ export function AndroidScreenHeader(props: { paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), }} > - + {props.onBack ? ( ; } + +/** + * Android in-flow screen wrapper for routes whose native stack header is + * disabled statically. Renders the AndroidScreenHeader above the content on + * Android and renders the content unchanged elsewhere (iOS keeps its native + * stack header). Used by route early states (loading / unavailable) so they + * keep navigation chrome even though the native header never mounts. + */ +export function AndroidHeaderScreen(props: { + readonly title: string; + readonly onBack?: () => void; + readonly children: ReactNode; +}) { + if (Platform.OS !== "android") { + return <>{props.children}; + } + return ( + + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 28f7cfe57a7f..a4bf3c600f79 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -33,6 +33,7 @@ export function brandTitleOffset(nativeLeadingItem: boolean): number { */ export function CompactBrandTitle( props: { + readonly allowFontScaling?: boolean; readonly nativeLeadingItem?: boolean; } = {}, ) { @@ -57,6 +58,7 @@ export function CompactBrandTitle( > + ); } - return ; + return ( + navigation.goBack() : undefined} + > + + + ); } if (cwd === null) { - return ; + return ( + navigation.goBack() : undefined} + > + + + ); } if (fileInspector.supported) { @@ -402,6 +424,19 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { subtitle={projectName} onBack={handleReturnToThread} actions={[ + ...(layout.usesSplitView + ? [ + { + accessibilityLabel: panes.primarySidebarVisible + ? "Hide thread sidebar" + : "Show thread sidebar", + icon: panes.primarySidebarVisible + ? "arrow.up.left.and.arrow.down.right" + : "sidebar.left", + onPress: togglePrimarySidebar, + } satisfies AndroidHeaderAction, + ] + : []), { accessibilityLabel: "Refresh files", icon: "arrow.clockwise", diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 7760920f7dbd..be516a46ec7a 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -2,6 +2,7 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo, useState } from "react"; +import { Platform } from "react-native"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -24,7 +25,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle /* ─── Route screen ───────────────────────────────────────────────────── */ export function HomeRouteScreen() { - const { layout } = useAdaptiveWorkspaceLayout(); + const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); @@ -104,7 +105,11 @@ export function HomeRouteScreen() { return ( <> [] }} + options={ + Platform.OS === "android" + ? { headerShown: false } + : { title: "", headerTitle: "", unstable_headerLeftItems: () => [] } + } /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onToggleSidebar={Platform.OS === "android" ? togglePrimarySidebar : undefined} + primarySidebarVisible={panes.primarySidebarVisible} /> ); @@ -127,15 +134,19 @@ export function HomeRouteScreen() { onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Restore the compact title after the split branch blanks the detail - header. The brand slot doubles as the connection status surface: - while an environment reconnects, the lockup fades to a status label - in place (no layout shift in the list below). */} + {/* Restore the header after leaving split view; screen options are + shallow-merged. Android never mounts the native Home header (it + draws its own in-flow header in compact and the sidebar brand in + split), so only iOS re-enables it here. The brand slot also doubles + as the connection status surface while an environment reconnects. */} - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }), - })} + options={{ + ...getConnectionAwareBrandHeaderOptions({ + onOpenEnvironments: () => + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }), + }), + headerShown: Platform.OS !== "android", + }} /> void; + readonly children: ReactNode; + readonly contentSettledWidth: number | null; +}) { + const swipeGesture = useMemo( + () => + Gesture.Pan() + .enabled(props.enabled) + .activeOffsetX([-12, 12]) + .failOffsetY([-32, 32]) + // Only swipes that begin inside the left-edge band toggle the sidebar. + // Horizontal content elsewhere (terminal, code blocks, diffs) scrolls + // on its own; letting this pan stay live across the whole pane would + // steal those gestures and flip the sidebar on a mid-screen swipe. + .onTouchesDown((event, stateManager) => { + if (!shouldStartWorkspaceSidebarSwipe(event.allTouches[0]?.x ?? 0)) { + stateManager.fail(); + } + }) + .onEnd((event) => { + if ( + !shouldToggleWorkspaceSidebarForSwipe({ + primarySidebarVisible: props.primarySidebarVisible, + translationX: event.translationX, + velocityX: event.velocityX, + }) + ) { + return; + } + runOnJS(props.onToggleSidebar)(); + }), + [props.enabled, props.onToggleSidebar, props.primarySidebarVisible], + ); + + const content = ( + + + {props.children} + + + ); + + return {content}; +} + /** * Register this screen's inspector pane content with the workspace column. * @@ -438,6 +508,10 @@ function AdaptiveWorkspaceLayoutContent( navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }); }, [navigation]); + const handleStartNewTask = useCallback(() => { + navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + }, [navigation]); + const handleNewThreadInProject = useCallback( (project: EnvironmentProject) => { navigation.navigate("NewTaskSheet", { @@ -522,30 +596,32 @@ function AdaptiveWorkspaceLayoutContent( pointerEvents={panes.primarySidebarVisible ? "auto" : "none"} style={sidebarAnimatedStyle} > - + + + + + ) : null} - - - {props.children} - - + + {props.children} + void }) { +export function WorkspaceEmptyDetail(props: { + readonly onStartNewTask?: () => void; + readonly onToggleSidebar?: () => void; + readonly primarySidebarVisible?: boolean; +}) { const iconColor = useThemeColor("--color-icon-subtle"); + const insets = useSafeAreaInsets(); return ( + {props.onToggleSidebar ? ( + + + + ) : null} Select a thread diff --git a/apps/mobile/src/features/layout/workspace-sidebar-swipe.test.ts b/apps/mobile/src/features/layout/workspace-sidebar-swipe.test.ts new file mode 100644 index 000000000000..9f033b5b1f9c --- /dev/null +++ b/apps/mobile/src/features/layout/workspace-sidebar-swipe.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + shouldStartWorkspaceSidebarSwipe, + shouldToggleWorkspaceSidebarForSwipe, + WORKSPACE_SIDEBAR_SWIPE_DISTANCE, +} from "./workspace-sidebar-swipe"; + +describe("shouldStartWorkspaceSidebarSwipe", () => { + it("accepts a swipe begun inside the left-edge band", () => { + expect(shouldStartWorkspaceSidebarSwipe(0)).toBe(true); + expect(shouldStartWorkspaceSidebarSwipe(WORKSPACE_SIDEBAR_SWIPE_DISTANCE)).toBe(true); + }); + + it("rejects a swipe begun in the middle of the content pane", () => { + expect(shouldStartWorkspaceSidebarSwipe(WORKSPACE_SIDEBAR_SWIPE_DISTANCE + 1)).toBe(false); + expect(shouldStartWorkspaceSidebarSwipe(400)).toBe(false); + }); +}); + +describe("shouldToggleWorkspaceSidebarForSwipe", () => { + it("hides a visible sidebar after a leftward swipe", () => { + expect( + shouldToggleWorkspaceSidebarForSwipe({ + primarySidebarVisible: true, + translationX: -72, + velocityX: 0, + }), + ).toBe(true); + }); + + it("shows a hidden sidebar after a rightward swipe", () => { + expect( + shouldToggleWorkspaceSidebarForSwipe({ + primarySidebarVisible: false, + translationX: 72, + velocityX: 0, + }), + ).toBe(true); + }); + + it("ignores short swipes in the wrong direction", () => { + expect( + shouldToggleWorkspaceSidebarForSwipe({ + primarySidebarVisible: true, + translationX: 72, + velocityX: 0, + }), + ).toBe(false); + }); + + it("accepts a quick fling in the requested direction", () => { + expect( + shouldToggleWorkspaceSidebarForSwipe({ + primarySidebarVisible: false, + translationX: 12, + velocityX: 700, + }), + ).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/layout/workspace-sidebar-swipe.ts b/apps/mobile/src/features/layout/workspace-sidebar-swipe.ts new file mode 100644 index 000000000000..5475cafad9a3 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace-sidebar-swipe.ts @@ -0,0 +1,27 @@ +export const WORKSPACE_SIDEBAR_SWIPE_DISTANCE = 72; +export const WORKSPACE_SIDEBAR_SWIPE_VELOCITY = 700; + +/** + * Only swipes that begin inside the left-edge band toggle the sidebar. + * Horizontal content elsewhere (terminal, code blocks, diffs) scrolls on its + * own; a pan left live across the whole pane would steal those gestures. + */ +export function shouldStartWorkspaceSidebarSwipe(startX: number): boolean { + "worklet"; + return startX <= WORKSPACE_SIDEBAR_SWIPE_DISTANCE; +} + +export function shouldToggleWorkspaceSidebarForSwipe(input: { + readonly primarySidebarVisible: boolean; + readonly translationX: number; + readonly velocityX: number; +}): boolean { + "worklet"; + + const direction = input.primarySidebarVisible ? -1 : 1; + + return ( + input.translationX * direction >= WORKSPACE_SIDEBAR_SWIPE_DISTANCE || + input.velocityX * direction >= WORKSPACE_SIDEBAR_SWIPE_VELOCITY + ); +} diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 1ebb3aaf7b16..b249806a8334 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -340,7 +340,8 @@ export function ReviewSheet(props: ReviewSheetProps) { const isAndroid = Platform.OS === "android"; const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); useAdaptiveWorkspacePaneRole("inspector"); - const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { layout, panes, showAuxiliaryPane, toggleAuxiliaryPane, togglePrimarySidebar } = + useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const colorScheme = useColorScheme(); @@ -658,6 +659,21 @@ export function ReviewSheet(props: ReviewSheetProps) { title="Review changes" subtitle={androidHeaderSubtitle || "Select a diff"} onBack={handleReturnToThread} + actions={ + layout.usesSplitView + ? [ + { + accessibilityLabel: panes.primarySidebarVisible + ? "Hide thread sidebar" + : "Show thread sidebar", + icon: panes.primarySidebarVisible + ? "arrow.up.left.and.arrow.down.right" + : "sidebar.left", + onPress: togglePrimarySidebar, + }, + ] + : undefined + } trailing={ showSectionToolbar ? ( ; + return ( + navigation.goBack() : undefined} + > + + + ); } return ( - - - + navigation.goBack() : undefined} + > + + + + ); } if (!selectedThreadProject?.workspaceRoot) { return ( - - - + navigation.goBack() : undefined} + > + + + + ); } if (!environment.isReady && environment.presentation === null) { - return ; + return ( + navigation.goBack() : undefined} + > + + + ); } return ( @@ -1099,21 +1127,23 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) title="Terminal" subtitle={headerSubtitle} onBack={navigation.canGoBack() ? () => navigation.goBack() : undefined} + actions={ + layout.usesSplitView + ? [ + { + accessibilityLabel: panes.primarySidebarVisible + ? "Hide thread sidebar" + : "Show thread sidebar", + icon: panes.primarySidebarVisible + ? "arrow.up.left.and.arrow.down.right" + : "sidebar.left", + onPress: togglePrimarySidebar, + }, + ] + : undefined + } trailing={ <> - {layout.usesSplitView ? ( - - ) : null} {isEnvironmentReady ? ( (null); const searchBarRef = useRef(null); const openSwipeableRef = useRef(null); - const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); const { archiveThread, @@ -750,8 +743,6 @@ function ThreadNavigationSidebarPane( const borderColor = useThemeColor("--color-border"); const mutedColor = useThemeColor("--color-foreground-muted"); const placeholderColor = useThemeColor("--color-placeholder"); - const headerFadeColor = String(backgroundColor); - const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); // The sticky header (title row, search field, optional connection status) // is measured so the list inset always matches its real height — no @@ -780,19 +771,10 @@ function ThreadNavigationSidebarPane( }, [props.onSelectThread], ); - const handleScroll = useCallback((event: NativeSyntheticEvent) => { - const next = event.nativeEvent.contentOffset.y > 6; - if (headerIsOverContentRef.current === next) { - return; - } - headerIsOverContentRef.current = next; - setHeaderIsOverContent(next); - }, []); const handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); // Project shells load after the first rows draw, so the maps they feed have @@ -1282,7 +1264,14 @@ function ThreadNavigationSidebarPane( contentContainerStyle={[ styles.threadListContent, { - paddingBottom: 16 + insets.bottom, + // The last row must clear the Android FAB (56px) plus its + // bottom offset and a gap so nothing sits underneath it. + // The list already sits inside a parent that applies the + // bottom inset, so subtract it here to avoid double-counting. + paddingBottom: + Platform.OS === "android" + ? Math.max(insets.bottom, 16) + 16 + 56 + 16 - insets.bottom + : 16 + insets.bottom, paddingTop: topListInset, }, ]} @@ -1301,53 +1290,26 @@ function ThreadNavigationSidebarPane( - - - - - - - - - - - - - {/* Title slot doubles as the connection status surface: while an - environment reconnects, "Threads" fades to a status label in + environment reconnects, the brand fades to a status label in place (no layout shift in the list below). */} - Threads - + + + } /> diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index d7754b7d78f7..d0c6d51624fe 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -22,6 +22,7 @@ import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState"; import { + AndroidHeaderScreen, AndroidScreenHeader, type AndroidHeaderAction, } from "../../components/AndroidScreenHeader"; @@ -97,7 +98,19 @@ function firstRouteParam(value: string | string[] | undefined): string | null { } function OpeningThreadLoadingScreen() { - return ; + const navigation = useNavigation(); + return ( + navigation.goBack() : undefined} + > + + + ); } type ThreadRouteScreenRouteProps = StaticScreenProps<{ @@ -111,22 +124,28 @@ interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { } function ThreadUnavailableScreen() { + const navigation = useNavigation(); return ( - navigation.goBack() : undefined} > - - + + + + ); } @@ -715,6 +734,15 @@ function ThreadRouteContent( onPress: handleToggleInspector, }); } + if (layout.usesSplitView) { + actions.push({ + accessibilityLabel: panes.primarySidebarVisible + ? "Hide thread sidebar" + : "Show thread sidebar", + icon: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left", + onPress: togglePrimarySidebar, + }); + } return actions; }, [ fileInspector.supported, @@ -722,9 +750,12 @@ function ThreadRouteContent( handleOpenTerminal, handleOpenGitInspector, handleToggleInspector, + layout.usesSplitView, + panes.primarySidebarVisible, props.onReturnToThread, selectedThreadCwd, selectedThreadProject?.workspaceRoot, + togglePrimarySidebar, ]); // Deep links / cold starts land with Thread as the ONLY route, where the diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx deleted file mode 100644 index 1321c82c0d8b..000000000000 --- a/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { View } from "react-native"; - -import { T3HeaderButton } from "../../native/T3HeaderButton.android"; -import type { SidebarHeaderActionsProps } from "./sidebar-header-actions"; - -export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { - return ( - - - - ); -} diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 6dea0beafbec..f717dc9bba67 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -66,6 +66,7 @@ describe("deriveLayout", () => { { name: "large iPhone landscape", width: 932, height: 430 }, { name: "short wide window", width: 1_024, height: 599 }, { name: "narrow tall window", width: 719, height: 1_024 }, + { name: "narrow split-screen window", width: 680, height: 900 }, ])("keeps a $name in the compact shell", ({ width, height }) => { expect(deriveLayout({ width, height })).toEqual({ variant: "compact", @@ -79,7 +80,8 @@ describe("deriveLayout", () => { { name: "small tablet portrait", width: 744, height: 1_133 }, { name: "tablet landscape", width: 1_024, height: 768 }, { name: "large resizable window", width: 1_366, height: 1_024 }, - { name: "foldable-sized window", width: 800, height: 700 }, + { name: "near-square foldable window", width: 800, height: 700 }, + { name: "tall foldable window", width: 800, height: 1_200 }, ])("uses the split shell for a $name", ({ width, height }) => { expect(deriveLayout({ width, height })).toMatchObject({ variant: "split", @@ -91,12 +93,26 @@ describe("deriveLayout", () => { expect( deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant, ).toBe("split"); - expect( - deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH - 1, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant, - ).toBe("compact"); - expect( - deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT - 1 }).variant, - ).toBe("compact"); + expect(deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH - 1, height: 768 }).variant).toBe( + "compact", + ); + expect(deriveLayout({ width: 1_024, height: SPLIT_LAYOUT_MIN_HEIGHT - 1 }).variant).toBe( + "compact", + ); + }); + + it("re-evaluates the shell as the available window changes", () => { + const dimensions = [ + { width: 680, height: 900 }, + { width: 1_024, height: 768 }, + { width: 680, height: 900 }, + ]; + + expect(dimensions.map((input) => deriveLayout(input).variant)).toEqual([ + "compact", + "split", + "compact", + ]); }); it("keeps the sidebar within usable native-column bounds", () => { @@ -312,6 +328,34 @@ describe("deriveWorkspacePaneLayout", () => { ).toBe(false); }); + it("expands and restores the content pane when the sidebar visibility changes", () => { + const layout = deriveLayout({ width: 1_024, height: 768 }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_024, + primarySidebarPreferredVisible: false, + auxiliaryPanePreferredVisible: false, + }), + ).toMatchObject({ + primarySidebarVisible: false, + contentPaneWidth: 1_024, + }); + + expect( + deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1_024, + primarySidebarPreferredVisible: true, + auxiliaryPanePreferredVisible: false, + }), + ).toMatchObject({ + primarySidebarVisible: true, + contentPaneWidth: 696, + }); + }); + it("never exposes workspace panes in compact layouts", () => { const layout = deriveLayout({ width: 430, height: 932 });