From e522e6ea8e25140d2ef0c819986175ad6d6379b8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 21:44:53 +0700 Subject: [PATCH] fix(app): make the compact Explorer tab rail usable on phones (#7589) Popovers on Android landed one status bar below their trigger. React Native 0.86 stopped subtracting the status bar from window coordinates under edge-to-edge, so measureInWindow, touch pageY and a full-screen Modal already share screen-top coordinates; the old StatusBar.currentHeight compensation in the menu engine, tooltip, context menu and sidebar long-press now double-shifted every anchor. Compact tabs keep their natural width and the rail scrolls as soon as they overflow, instead of squeezing labels first and then scrolling anyway. They drop the inline close because the long-press sheet already closes a tab and a narrow X beside the label is easy to hit. Any scrolling rail keeps natural widths rather than collapsing every chip to the minimum, and the rail resets its offset when scrolling ends so tabs scrolled out of view come back. The Explorer rail paints the sidebar surface, so its chips climb the sidebar hover and selected tokens. Light themes paint the sidebar with surface2, which made the surface2 active chip invisible there. The compact Explorer closes with the same X as the compact agent list, and the compact + menu omits Diff because compact Changes already shows diffs inline. --- docs/explorer-sidebar.md | 7 +- docs/floating-panels.md | 10 ++- .../headers/sidebar-close-button.tsx | 59 ++++++++++++ packages/app/src/components/left-sidebar.tsx | 51 +++-------- .../use-long-press-drag-interaction.ts | 5 +- .../app/src/components/ui/context-menu.tsx | 5 +- .../src/components/ui/menu/menu-overlay.tsx | 10 +-- packages/app/src/components/ui/tooltip.tsx | 4 +- .../workspace/use-workspace-tab-layout.ts | 4 +- .../workspace/workspace-desktop-tabs-row.tsx | 71 ++++++++------- .../workspace/workspace-explorer-toggle.tsx | 12 +++ .../workspace/workspace-tab-chip-backdrop.ts | 30 +++++++ .../workspace/workspace-tab-layout.test.ts | 90 ++++++++++++++++++- .../screens/workspace/workspace-tab-layout.ts | 28 ++++-- .../app/src/workspace-tabs/launcher/index.tsx | 6 +- 15 files changed, 289 insertions(+), 103 deletions(-) create mode 100644 packages/app/src/components/headers/sidebar-close-button.tsx create mode 100644 packages/app/src/screens/workspace/workspace-tab-chip-backdrop.ts diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 9fe6bdfd7b..7207d14f87 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -83,7 +83,12 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes hide their close buttons through the panel manifest; close them from the tab context menu. -Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock, including Agent and terminal profiles. +Other tabs reveal the close control on hover. Compact layouts show no inline close on any tab, +since a narrow X beside the label is easy to hit by accident; the long-press menu closes it. +Compact tabs also keep their natural width and the rail scrolls as soon as they overflow, while +pointer layouts shrink tabs toward the clickable minimum first. A rail that scrolls never truncates. +The + menu opens compatible panels in the dock, including Agent and terminal profiles. Compact +layouts omit Diff from it because compact Changes shows each file's diff inline. Web also supports dragging agents and terminals into Explorer. Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab diff --git a/docs/floating-panels.md b/docs/floating-panels.md index c183dc6615..39ef49fae8 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -282,10 +282,12 @@ left = anchorRect.x - hostRect.x; bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset; ``` -Do this before adding any platform offset. If anchor and host are both measured -with `measureInWindow`, Android's status-bar coordinate behavior cancels out. -Only add a status-bar offset when the render surface is not measured in the same -coordinate system. See `tooltip.tsx` for that separate case. +Do this before adding any platform offset. The app draws edge-to-edge on Android, so +`measureInWindow`, touch `pageY`, and a full-screen `Modal` all share one coordinate +space that starts at the screen top. Do not add `StatusBar.currentHeight` to an anchor: +React Native 0.86 stopped subtracting the status bar from window coordinates under +edge-to-edge, and the old compensation now lands every menu, tooltip, and context menu +one status bar below its trigger. ## Gotcha 5 — The two-measurement flash diff --git a/packages/app/src/components/headers/sidebar-close-button.tsx b/packages/app/src/components/headers/sidebar-close-button.tsx new file mode 100644 index 0000000000..3beaa1f7a9 --- /dev/null +++ b/packages/app/src/components/headers/sidebar-close-button.tsx @@ -0,0 +1,59 @@ +import { X } from "lucide-react-native"; +import type { StyleProp, ViewStyle } from "react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { + IconButtonChromeProvider, + iconButtonChromeGlyphSize, + mutedIconColorMapping, +} from "@/components/ui/icon-button-chrome"; +import type { ShortcutKey } from "@/utils/format-shortcut"; + +const ThemedX = withUnistyles(X); +const NO_SHORTCUT: ShortcutKey[] = []; + +interface SidebarCloseButtonProps { + label: string; + onPress: () => void; + testID: string; + nativeID?: string; + accessibilityState?: { expanded: boolean }; + style?: StyleProp; +} + +/** + * The X that closes a compact sidebar overlay. It is the compact header's icon button, so the + * sidebar's close reads like the header it covers; every compact sidebar uses it. + */ +export function SidebarCloseButton({ + label, + onPress, + testID, + nativeID, + accessibilityState, + style, +}: SidebarCloseButtonProps) { + return ( + + + + + + ); +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 6cf9e29eb1..79f836d1d4 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -5,7 +5,6 @@ import { GitBranch, Server, Settings, - X, type LucideIcon, } from "lucide-react-native"; import { useTranslation } from "react-i18next"; @@ -21,14 +20,9 @@ import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; -import { - IconButtonChromeProvider, - iconButtonChromeGlyphSize, - mutedIconColorMapping, -} from "@/components/ui/icon-button-chrome"; +import { SidebarCloseButton } from "@/components/headers/sidebar-close-button"; import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, @@ -68,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; -import type { ShortcutKey } from "@/utils/format-shortcut"; import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; import { @@ -81,8 +74,6 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; -const ThemedX = withUnistyles(X); - type SidebarTheme = ReturnType["theme"]; const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null; @@ -555,7 +546,15 @@ function MobileSidebar({ }, [closeSidebar]); const closeButton = useMemo( - () => , + () => ( + + ), [labels.closeSidebar, closeSidebar], ); @@ -621,34 +620,6 @@ function MobileSidebar({ ); } -const NO_SHORTCUT: ShortcutKey[] = []; - -/** The compact header's icon button, so the sidebar's close reads like the header it covers. */ -function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) { - return ( - - - - - - ); -} - function DesktopSidebar({ theme, workspaceGroups, diff --git a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts index 8e3a508b3b..ae1d67dce3 100644 --- a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts +++ b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; -import { Platform, StatusBar, type GestureResponderEvent } from "react-native"; +import type { GestureResponderEvent } from "react-native"; import * as Haptics from "expo-haptics"; import { isWeb as platformIsWeb } from "@/constants/platform"; import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; @@ -36,10 +36,9 @@ export function useLongPressDragInteraction(input: { if (!menuController || !touchStartRef.current) { return; } - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; menuController.setAnchorRect({ x: touchStartRef.current.x, - y: touchStartRef.current.y + statusBarHeight, + y: touchStartRef.current.y, width: 0, height: 0, }); diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 86dd7e978b..995caf4a0b 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -6,8 +6,6 @@ import { type Ref, } from "react"; import { - Platform, - StatusBar, View, type GestureResponderEvent, type PressableProps, @@ -133,10 +131,9 @@ export function ContextMenuTrigger({ const point = coerceEventPoint(event); if (!point) return; - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; ctx.setAnchorRect({ x: point.pageX, - y: point.pageY + statusBarHeight, + y: point.pageY, width: 0, height: 0, }); diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index bc77ab58f4..9631dbbe9a 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -14,7 +14,6 @@ import { Modal, Platform, Pressable, - StatusBar, View, type StyleProp, type ViewStyle, @@ -162,16 +161,13 @@ export function useAnchoredPosition({ return undefined; } - // Capture status bar height synchronously before async measurement, so it cannot change - // or read back null between the measure call and its resolution. - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; let cancelled = false; + // The app draws edge-to-edge, so measureInWindow and the full-screen Modal share one + // coordinate space that starts at the screen top; no status bar offset applies. void measureElement(anchorRef.current).then((rect) => { if (cancelled) return undefined; - // On Android with statusBarTranslucent, measureInWindow returns coordinates relative to - // below the status bar while Modal content starts at the screen top. - setTriggerRect({ ...rect, y: rect.y + statusBarHeight }); + setTriggerRect(rect); return undefined; }); diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 72803f06d0..ae00768a34 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -18,7 +18,6 @@ import { Platform, Modal, Pressable, - StatusBar, View, type PressableProps, type StyleProp, @@ -474,11 +473,10 @@ export function TooltipContent({ return () => {}; } - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; let cancelled = false; void measureElement(ctx.triggerRef.current).then((rect) => { - if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight }); + if (!cancelled) setTriggerRect(rect); return undefined; }); 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..367300a613 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts @@ -10,6 +10,7 @@ interface UseWorkspaceTabLayoutInput { tabLabelWidths: number[]; viewportWidthOverride?: number | null; metrics: WorkspaceTabLayoutMetrics; + compact: boolean; } interface UseWorkspaceTabLayoutResult { @@ -31,8 +32,9 @@ export function useWorkspaceTabLayout( viewportWidth: resolvedViewportWidth, tabLabelWidths: input.tabLabelWidths, metrics: input.metrics, + compact: input.compact, }), - [input.metrics, input.tabLabelWidths, resolvedViewportWidth], + [input.compact, input.metrics, input.tabLabelWidths, resolvedViewportWidth], ); return { 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 8074f4415f..f7645ac168 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -10,7 +10,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 { useFilePanelOpenWithMenu } from "@/file-panels"; import { CopyX, @@ -68,6 +68,10 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs- import type { PaneHost } from "@/panels/panel-manifest"; import type { WorkspaceTabLaunchPurpose } from "@/workspace-tabs/launcher"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; +import { + resolveWorkspaceTabChipBackdrop, + type WorkspaceTabRailSurface, +} from "@/screens/workspace/workspace-tab-chip-backdrop"; import type { Theme } from "@/styles/theme"; import { RenderProfile } from "@/utils/render-profiler"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; @@ -659,18 +663,6 @@ function useMiddleClickClose(onClose: () => void) { return ref; } -/** The chip fill the running-status ring has to knock out of. Mirrors `styles.tab*` exactly. */ -function resolveChipBackdrop({ - isActiveFocused, - isFilled, -}: { - isActiveFocused: boolean; - isFilled: boolean; -}): SurfaceBackdrop { - if (isActiveFocused) return "surface2"; - return isFilled ? "surface1" : "surface0"; -} - function TabHandleContent({ presentation, isHighlighted, @@ -730,6 +722,7 @@ function TabChip({ resolvedTabWidth, showLabel, showCloseButton, + rail, isCloseHovered, isClosingTab, presentation, @@ -750,6 +743,8 @@ function TabChip({ resolvedTabWidth: number; showLabel: boolean; showCloseButton: boolean; + /** The surface the row paints, so chip fills and their knockouts match the host. */ + rail: WorkspaceTabRailSurface; isCloseHovered: boolean; isClosingTab: boolean; presentation: WorkspaceTabPresentation; @@ -777,9 +772,11 @@ function TabChip({ const isActiveFocused = isActive && isFocused; const isHovered = hovered || isCloseHovered; const isHighlighted = isActiveFocused || isHovered; - const chipBackdrop: SurfaceBackdrop = resolveChipBackdrop({ + const isFilled = isActive || isHovered; + const chipBackdrop: SurfaceBackdrop = resolveWorkspaceTabChipBackdrop({ + rail, isActiveFocused, - isFilled: isActive || isHovered, + isFilled, }); const showCloseControl = showCloseButton && (isHovered || isNative || isCompact || isClosingTab); const closeButtonDragBlockers = isWeb @@ -796,9 +793,7 @@ function TabChip({ const tabChipStyle = useCallback( () => [ styles.tab, - isActiveFocused && styles.tabActive, - isActive && !isFocused && styles.tabActiveUnfocused, - !isActive && isHovered && styles.tabHovered, + isFilled && styles.tabFill(chipBackdrop), isWeb && isDragging && ({ cursor: "grabbing" } as object), { minWidth: resolvedTabWidth, @@ -806,7 +801,7 @@ function TabChip({ maxWidth: resolvedTabWidth, }, ], - [isActive, isActiveFocused, isDragging, isFocused, isHovered, resolvedTabWidth], + [chipBackdrop, isDragging, isFilled, resolvedTabWidth], ); const handleTabPointerEnter = useCallback(() => { @@ -1058,8 +1053,10 @@ function ResolvedWorkspaceDesktopTabsRow({ onExitFocusMode, }: ResolvedWorkspaceDesktopTabsRowProps) { const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); const newTabKeys = useShortcutKeys("workspace-tab-new"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); + const tabsScrollRef = useRef(null); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); const tabScrollBoundary = useHorizontalScrollBoundary(); const [labelMeasurements, setLabelMeasurements] = useState( @@ -1221,7 +1218,15 @@ function ResolvedWorkspaceDesktopTabsRow({ tabLabelWidths: trackSnapshot?.labelWidths ?? [], viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, metrics: layoutMetrics, + compact: isCompact, }); + // A scroll view keeps its offset when its content shrinks under the viewport, so a row that + // stops scrolling would leave the tabs it had panned past out of view with no way back. + useEffect(() => { + if (!layout.requiresHorizontalScrollFallback) { + tabsScrollRef.current?.scrollTo({ x: 0, animated: false }); + } + }, [layout.requiresHorizontalScrollFallback]); const handleDragEnd = useCallback( (nextTabs: ResolvedWorkspaceDesktopTabRowItem[]) => { @@ -1266,6 +1271,9 @@ function ResolvedWorkspaceDesktopTabsRow({ handle: handleNewTabKeyboardAction, }); + const rail: WorkspaceTabRailSurface = host === "explorer" ? "sidebar" : "surface"; + const onSidebar = rail === "sidebar"; + const renderTab = useCallback( ({ item, @@ -1306,6 +1314,7 @@ function ResolvedWorkspaceDesktopTabsRow({ resolvedTabWidth={resolvedTabWidth} showLabel={showLabel} showCloseButton={shouldShowCloseButton} + rail={rail} setHoveredCloseTabKey={setHoveredCloseTabKey} onNavigateTab={onNavigateTab} onCloseTab={onCloseTab} @@ -1334,6 +1343,7 @@ function ResolvedWorkspaceDesktopTabsRow({ onNavigateTab, onReloadAgent, onRenameTab, + rail, setHoveredCloseTabKey, tabMenuLabels, tabDropPreviewIndex, @@ -1353,7 +1363,7 @@ function ResolvedWorkspaceDesktopTabsRow({ const row = ( @@ -1379,6 +1389,7 @@ function ResolvedWorkspaceDesktopTabsRow({ /> >; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; @@ -1560,6 +1573,7 @@ function ResolvedDesktopTabChip({ resolvedTabWidth={resolvedTabWidth} showLabel={showLabel} showCloseButton={showCloseButton} + rail={rail} isCloseHovered={item.isCloseHovered} isClosingTab={item.isClosingTab} presentation={presentation} @@ -1589,6 +1603,9 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", overflow: "visible", }, + tabsContainerSidebar: { + backgroundColor: theme.colors.surfaceSidebar, + }, tabsScroll: { minWidth: 0, }, @@ -1638,15 +1655,9 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], userSelect: "none", }, - tabHovered: { - backgroundColor: theme.colors.surface1, - }, - tabActive: { - backgroundColor: theme.colors.surface2, - }, - tabActiveUnfocused: { - backgroundColor: theme.colors.surface1, - }, + tabFill: (backdrop: SurfaceBackdrop) => ({ + backgroundColor: theme.colors[backdrop], + }), tabHoverFrame: { position: "relative", }, diff --git a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx index 55a36f9779..1d4a989f07 100644 --- a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx +++ b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx @@ -2,6 +2,7 @@ import { PanelRight } from "lucide-react-native"; import { type StyleProp, type ViewStyle } from "react-native"; import { withUnistyles } from "react-native-unistyles"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { SidebarCloseButton } from "@/components/headers/sidebar-close-button"; import { extraMutedIconColorMapping, iconButtonChromeGlyphSize, @@ -93,5 +94,16 @@ export function WorkspaceExplorerSidebarToggle({ ...toggleProps }: DesktopWorkspaceExplorerToggleProps) { if (owner === "header") return null; + if (owner === "mobile") { + // The compact Explorer is an overlay like the compact agent list, so it closes with the same X. + return ( + + ); + } return ; } diff --git a/packages/app/src/screens/workspace/workspace-tab-chip-backdrop.ts b/packages/app/src/screens/workspace/workspace-tab-chip-backdrop.ts new file mode 100644 index 0000000000..3484422c4e --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-tab-chip-backdrop.ts @@ -0,0 +1,30 @@ +import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; + +/** The surface a tab rail paints: a pane header or the Explorer sidebar. */ +export type WorkspaceTabRailSurface = "surface" | "sidebar"; + +/** + * The fill of a tab chip, named as the surface token it paints so the chip style and everything + * knocking out of it (the running-status ring, the trailing-action scrim) agree. + * + * Each rail climbs its own ladder: resting, filled (hovered, or active in an unfocused pane), + * selected. The sidebar rail uses the sidebar hover/selected tokens rather than the plain surface + * steps because light themes paint `surfaceSidebar` with `surface2`, so a `surface2` chip vanishes + * on it. + */ +export function resolveWorkspaceTabChipBackdrop({ + rail, + isActiveFocused, + isFilled, +}: { + rail: WorkspaceTabRailSurface; + isActiveFocused: boolean; + isFilled: boolean; +}): SurfaceBackdrop { + if (rail === "sidebar") { + if (isActiveFocused) return "surfaceSidebarSelected"; + return isFilled ? "surfaceSidebarHover" : "surfaceSidebar"; + } + if (isActiveFocused) return "surface2"; + return isFilled ? "surface1" : "surface0"; +} diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 14e7b89b31..7c7b98cee0 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -3,6 +3,9 @@ import { WorkspaceExplorerSidebarToggle, } from "./workspace-explorer-toggle"; import { describe, expect, it } from "vitest"; +import { SidebarCloseButton } from "@/components/headers/sidebar-close-button"; +import { resolveWorkspaceTabChipBackdrop } from "@/screens/workspace/workspace-tab-chip-backdrop"; +import { darkPureBlackTheme, darkTheme, lightTheme } from "@/styles/theme"; import { computeWorkspaceTabLayout, retainWorkspaceTabMeasuredWidth, @@ -27,6 +30,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 1200, tabLabelWidths: [56, 70, 49], metrics, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); @@ -41,6 +45,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 300, tabLabelWidths: [29, 52], metrics: { ...metrics, actionsReservedWidth: 36 }, + compact: false, }); expect(result.items).toEqual([ { width: 64, showLabel: true }, @@ -54,6 +59,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 1200, tabLabelWidths: [105], metrics, + compact: false, }); expect(result.requiresHorizontalScrollFallback).toBe(false); @@ -65,6 +71,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 460, tabLabelWidths: [168, 84, 56], metrics, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); @@ -83,6 +90,7 @@ describe("computeWorkspaceTabLayout", () => { rowPaddingHorizontal: 0, tabGap: 0, }, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); @@ -95,6 +103,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 404, tabLabelWidths: [98, 98, 98, 98], metrics, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); @@ -103,16 +112,43 @@ describe("computeWorkspaceTabLayout", () => { expect(result.items.every((item) => item.showLabel)).toBe(true); }); - it("uses horizontal scroll rather than shrinking below the clickable minimum", () => { + it("scrolls at natural widths rather than shrinking below the clickable minimum", () => { const result = computeWorkspaceTabLayout({ viewportWidth: 403, tabLabelWidths: [98, 98, 98, 98], metrics, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]); + expect(result.items.map((item) => item.width)).toEqual([132, 132, 132, 132]); + expect(result.items.every((item) => item.showLabel)).toBe(true); + }); + + it("keeps natural widths and no inline close on compact layouts while the tabs fit", () => { + const result = computeWorkspaceTabLayout({ + viewportWidth: 600, + tabLabelWidths: [56, 70, 49], + metrics, + compact: true, + }); + + expect(result.closeButtonPolicy).toBe("none"); + expect(result.requiresHorizontalScrollFallback).toBe(false); + expect(result.items.map((item) => item.width)).toEqual([90, 104, 83]); + }); + + it("scrolls compact layouts as soon as natural widths overflow instead of squeezing labels", () => { + const result = computeWorkspaceTabLayout({ + viewportWidth: 460, + tabLabelWidths: [168, 84, 56], + metrics, + compact: true, + }); + + expect(result.requiresHorizontalScrollFallback).toBe(true); + expect(result.items.map((item) => item.width)).toEqual([160, 118, 90]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); @@ -121,6 +157,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 1200, tabLabelWidths: [], metrics, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); @@ -133,6 +170,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 1200, tabLabelWidths: [68, 104], metrics, + compact: false, }); expect(result.items.map((item) => item.width)).toEqual([102, 138]); @@ -169,4 +207,52 @@ describe("Explorer toggle ownership", () => { expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner })).not.toBeNull(); } }); + + it("closes the compact overlay with the same X as the compact agent list", () => { + expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner: "mobile" })?.type).toBe( + SidebarCloseButton, + ); + expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner: "window" })?.type).not.toBe( + SidebarCloseButton, + ); + }); +}); + +describe("resolveWorkspaceTabChipBackdrop", () => { + const themes = { light: lightTheme, dark: darkTheme, pureBlack: darkPureBlackTheme }; + const rails = { + sidebar: "surfaceSidebar", + surface: "surface0", + } as const; + + it("paints the active chip in a colour the rail does not already use, in every theme", () => { + for (const [, theme] of Object.entries(themes)) { + for (const [rail, resting] of Object.entries(rails) as Array< + [keyof typeof rails, (typeof rails)[keyof typeof rails]] + >) { + const active = resolveWorkspaceTabChipBackdrop({ + rail, + isActiveFocused: true, + isFilled: true, + }); + const filled = resolveWorkspaceTabChipBackdrop({ + rail, + isActiveFocused: false, + isFilled: true, + }); + expect(theme.colors[active]).not.toBe(theme.colors[resting]); + expect(theme.colors[filled]).not.toBe(theme.colors[resting]); + expect(theme.colors[active]).not.toBe(theme.colors[filled]); + } + } + }); + + it("rests on the rail surface when the chip is neither active nor hovered", () => { + expect( + resolveWorkspaceTabChipBackdrop({ rail: "sidebar", isActiveFocused: false, isFilled: false }), + ).toBe("surfaceSidebar"); + expect( + resolveWorkspaceTabChipBackdrop({ rail: "surface", isActiveFocused: false, isFilled: false }), + ).toBe("surface0"); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index 2693e24cd7..cb89d9c54c 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -1,4 +1,4 @@ -export type WorkspaceTabCloseButtonPolicy = "all"; +export type WorkspaceTabCloseButtonPolicy = "all" | "none"; export interface WorkspaceTabLayoutMetrics { rowHorizontalInset: number; @@ -17,6 +17,12 @@ export interface WorkspaceTabLayoutInput { viewportWidth: number; tabLabelWidths: number[]; metrics: WorkspaceTabLayoutMetrics; + /** + * Compact layouts are touch-first. Tabs keep their natural width and the row scrolls as soon as + * they overflow, so a label is never squeezed to make room; chips carry no inline close because a + * narrow X beside the label is easy to hit by accident and the tab's long-press menu closes it. + */ + compact: boolean; } export interface WorkspaceTabLayoutItem { @@ -54,10 +60,11 @@ export function computeWorkspaceTabLayout( input: WorkspaceTabLayoutInput, ): WorkspaceTabLayoutResult { const tabCount = input.tabLabelWidths.length; + const closeButtonPolicy: WorkspaceTabCloseButtonPolicy = input.compact ? "none" : "all"; if (tabCount === 0) { return { items: [], - closeButtonPolicy: "all", + closeButtonPolicy, requiresHorizontalScrollFallback: false, }; } @@ -79,12 +86,19 @@ export function computeWorkspaceTabLayout( ); const naturalTotalWidth = naturalWidths.reduce((total, width) => total + width, 0); const minimumTotalWidth = input.metrics.minTabWidth * tabCount; - const requiresHorizontalScrollFallback = availableTabsWidth < minimumTotalWidth; + // A row that scrolls has no reason to truncate: the tabs keep their natural widths and the + // viewport pans over them. Pointer layouts first shrink toward the clickable minimum and scroll + // only once even that does not fit; compact layouts skip the shrink. + const requiresHorizontalScrollFallback = input.compact + ? naturalTotalWidth > availableTabsWidth + : availableTabsWidth < minimumTotalWidth; let resolvedWidths = naturalWidths; - if (requiresHorizontalScrollFallback) { - resolvedWidths = Array.from({ length: tabCount }, () => input.metrics.minTabWidth); - } else if (naturalTotalWidth > availableTabsWidth) { + if ( + !requiresHorizontalScrollFallback && + !input.compact && + naturalTotalWidth > availableTabsWidth + ) { const widthToRemove = naturalTotalWidth - availableTabsWidth; const shrinkCapacity = naturalTotalWidth - minimumTotalWidth; const shrinkRatio = widthToRemove / shrinkCapacity; @@ -102,7 +116,7 @@ export function computeWorkspaceTabLayout( width, showLabel: width > tabChromeWidth, })), - closeButtonPolicy: "all", + closeButtonPolicy, requiresHorizontalScrollFallback, }; } diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index 8da5a563ee..2029808f31 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -10,6 +10,7 @@ import { useRouter, type Href } from "expo-router"; import { useTranslation } from "react-i18next"; import { Globe, SquarePen, SquareTerminal } from "lucide-react-native"; import invariant from "tiny-invariant"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { pluginIconComponent } from "@/plugins/icons"; import { useInstalledPlugins } from "@/plugins/registry"; @@ -107,6 +108,7 @@ export function useWorkspaceTabLaunchCatalog(input: { invariant(launcher, "NewTabLauncherProvider is required"); const { config } = useDaemonConfig(serverId); const plugins = useInstalledPlugins(); + const isCompact = useIsCompactFormFactor(); ensurePanelsRegistered(); const launchSelection = useCallback( @@ -159,7 +161,8 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-changes", disabled: false, panelKind: "working_diff", - hidden: !launcher.showChanges, + // Compact Changes shows each file's diff inline, so a separate Diff panel is redundant. + hidden: !launcher.showChanges || isCompact, launch: launchSelection(BUILT_IN_SELECTIONS.diff), }, files: { @@ -252,6 +255,7 @@ export function useWorkspaceTabLaunchCatalog(input: { }, [ config?.terminalProfiles, editTerminalProfiles, + isCompact, launchSelection, launcher, plugins,