mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 01:32:40 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<IconButtonChromeProvider value="header-island">
|
||||
<HeaderToggleButton
|
||||
onPress={onPress}
|
||||
tooltipLabel={label}
|
||||
tooltipKeys={NO_SHORTCUT}
|
||||
tooltipSide="bottom"
|
||||
style={style}
|
||||
testID={testID}
|
||||
nativeID={nativeID}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={accessibilityState}
|
||||
>
|
||||
<ThemedX
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
strokeWidth={1.5}
|
||||
uniProps={mutedIconColorMapping}
|
||||
/>
|
||||
</HeaderToggleButton>
|
||||
</IconButtonChromeProvider>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof useUnistyles>["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(
|
||||
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
|
||||
() => (
|
||||
<SidebarCloseButton
|
||||
label={labels.closeSidebar}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
style={styles.mobileCloseButton}
|
||||
/>
|
||||
),
|
||||
[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 (
|
||||
<IconButtonChromeProvider value="header-island">
|
||||
<HeaderToggleButton
|
||||
onPress={onPress}
|
||||
tooltipLabel={label}
|
||||
tooltipKeys={NO_SHORTCUT}
|
||||
tooltipSide="bottom"
|
||||
style={styles.mobileCloseButton}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<ThemedX
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
strokeWidth={1.5}
|
||||
uniProps={mutedIconColorMapping}
|
||||
/>
|
||||
</HeaderToggleButton>
|
||||
</IconButtonChromeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopSidebar({
|
||||
theme,
|
||||
workspaceGroups,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<number>(0);
|
||||
const tabsScrollRef = useRef<ScrollView>(null);
|
||||
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(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 = (
|
||||
<View
|
||||
style={styles.tabsContainer}
|
||||
style={[styles.tabsContainer, onSidebar && styles.tabsContainerSidebar]}
|
||||
testID="workspace-tabs-row"
|
||||
onLayout={handleTabsContainerLayout}
|
||||
>
|
||||
@@ -1379,6 +1389,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
/>
|
||||
<View style={styles.tabsScrollContainer}>
|
||||
<Animated.ScrollView
|
||||
ref={tabsScrollRef}
|
||||
horizontal
|
||||
scrollEnabled={layout.requiresHorizontalScrollFallback}
|
||||
testID="workspace-tabs-scroll"
|
||||
@@ -1415,7 +1426,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
</Animated.ScrollView>
|
||||
<HorizontalScrollBoundaryShades
|
||||
visible={layout.requiresHorizontalScrollFallback}
|
||||
backdrop="surface"
|
||||
backdrop={rail}
|
||||
testIDPrefix="workspace-tabs-scroll-shade"
|
||||
leftStyle={tabScrollBoundary.leftShadeStyle}
|
||||
rightStyle={tabScrollBoundary.rightShadeStyle}
|
||||
@@ -1461,6 +1472,7 @@ function ResolvedDesktopTabChip({
|
||||
resolvedTabWidth,
|
||||
showLabel,
|
||||
showCloseButton,
|
||||
rail,
|
||||
setHoveredCloseTabKey,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
@@ -1488,6 +1500,7 @@ function ResolvedDesktopTabChip({
|
||||
resolvedTabWidth: number;
|
||||
showLabel: boolean;
|
||||
showCloseButton: boolean;
|
||||
rail: WorkspaceTabRailSurface;
|
||||
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | 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",
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<SidebarCloseButton
|
||||
label={toggleProps.label}
|
||||
onPress={toggleProps.onPress}
|
||||
testID="workspace-explorer-toggle"
|
||||
accessibilityState={toggleProps.accessibilityState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <WorkspaceExplorerToggle {...toggleProps} mobile={false} />;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user