Keep desktop sidebar controls stable across panel changes (#2078)

* fix(app): stabilize desktop sidebar controls

Keep the left-window-controls toggle mounted across sidebar state changes so its position and tooltip state remain stable. Preserve browser placement and show an explicit Explorer close control only where native right-side controls do not provide one.

* fix(app): suppress inactive sidebar chrome
This commit is contained in:
Mohamed Boudra
2026-07-15 15:30:57 +08:00
committed by GitHub
parent 9c40cb7637
commit 77f6069ec1
10 changed files with 326 additions and 33 deletions
@@ -5,10 +5,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
import { HeaderToggleButton } from "./header-toggle-button";
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
interface MenuHeaderProps {
title?: string;
@@ -42,15 +43,18 @@ function MobileMenuIcon({ color }: { color: string }) {
);
}
export function SidebarMenuToggle({
style,
function SidebarMenuToggleButton({
isMobile,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleShortcutKeys = useMemo(
@@ -58,9 +62,6 @@ export function SidebarMenuToggle({
[],
);
const menuIconColor =
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
const handlePress = useCallback(() => {
toggleAgentListForLayout({ isCompact: isMobile });
}, [toggleAgentListForLayout, isMobile]);
@@ -75,21 +76,54 @@ export function SidebarMenuToggle({
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={style}
style={resolvedStyle}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{isMobile ? (
<MobileMenuIcon color={menuIconColor} />
) : (
<PanelLeft size={theme.iconSize.md} color={menuIconColor} />
)}
{({ hovered, pressed }) => {
const color = hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
return isMobile ? (
<MobileMenuIcon color={color} />
) : (
<PanelLeft size={theme.iconSize.md} color={color} />
);
}}
</HeaderToggleButton>
);
}
export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const isMobile = useIsCompactFormFactor();
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
const placeholderStyle = useMemo(
() => [headerIconSlotStyle.slot, resolvedStyle],
[resolvedStyle],
);
if (!isMobile && !ownsTopLeft) {
return null;
}
if (!isMobile && hasTopLeftWindowControls) {
return (
<View pointerEvents="none" style={placeholderStyle}>
<View style={styles.desktopMenuIconSpace} />
</View>
);
}
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
}
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return <SidebarMenuToggleButton {...props} isMobile={false} resolvedStyle={resolvedStyle} />;
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
return (
<ScreenHeader
@@ -107,6 +141,12 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
}
const styles = StyleSheet.create((theme) => ({
leadingToggle: {
marginLeft: {
xs: 0,
md: -theme.spacing[2],
},
},
left: {
gap: theme.spacing[2],
},
@@ -116,6 +156,10 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between",
alignItems: "flex-start",
},
desktopMenuIconSpace: {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT,
@@ -38,7 +38,7 @@ export function ScreenHeader({
const isMobile = useIsCompactFormFactor();
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3];
const innerStyle = useMemo(
() => [styles.inner, { paddingTop: insets.top + topPadding }],