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
@@ -175,6 +175,24 @@ test.describe("Half-screen desktop layout", () => {
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible(); await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
}); });
test("keeps the left toggle center-owned without left window controls", async ({ page }) => {
await gotoAppShell(page);
const openToggle = page.getByTestId("menu-button");
const openBounds = await openToggle.locator("svg").first().boundingBox();
expect(openBounds).not.toBeNull();
expect(openBounds?.x).toBeGreaterThan(12);
await openToggle.click();
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
const closedToggle = page.getByTestId("menu-button");
const closedBounds = await closedToggle.locator("svg").first().boundingBox();
expect(closedBounds).not.toBeNull();
expect(closedBounds?.x).toBeCloseTo(12, 0);
expect(closedBounds?.y).toBe(openBounds?.y);
});
test("yields app navigation to the settings split", async ({ page }) => { test("yields app navigation to the settings split", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await page.getByTestId("sidebar-settings").click(); await page.getByTestId("sidebar-settings").click();
@@ -196,13 +214,40 @@ test.describe("Half-screen desktop layout", () => {
await expect( await expect(
page.getByTestId("explorer-tab-files").filter({ visible: true }).first(), page.getByTestId("explorer-tab-files").filter({ visible: true }).first(),
).toBeVisible(); ).toBeVisible();
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
await expect(page.getByTestId("explorer-close")).toBeVisible();
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
const centerBounds = await page.getByTestId("workspace-tabs-row").first().boundingBox();
const headerGlyphBounds = await page
.getByTestId("menu-button")
.locator("svg")
.first()
.boundingBox();
const tabGlyphBounds = await page
.locator('[data-testid^="workspace-tab-"]')
.first()
.locator("svg")
.first()
.boundingBox();
expect(centerBounds).not.toBeNull();
expect(headerGlyphBounds).not.toBeNull();
expect(tabGlyphBounds).not.toBeNull();
expect((headerGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0)).toBeCloseTo(
(tabGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0),
0,
);
await expect await expect
.poll( .poll(
async () => async () =>
(await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0, (await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0,
) )
.toBeGreaterThanOrEqual(400); .toBeGreaterThanOrEqual(400);
await page.getByTestId("explorer-close").click();
await expect(page.getByTestId("explorer-tab-files")).not.toBeVisible();
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
+45 -5
View File
@@ -28,6 +28,7 @@ import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { LeftSidebar } from "@/components/left-sidebar"; import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host"; import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProjectPickerModal } from "@/components/project-picker-modal";
@@ -37,9 +38,14 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal"; import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser"; import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import {
getIsElectronRuntime,
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
import { import {
canDesktopAppSidebarShare, canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum, resolveDesktopAppContentMinimum,
} from "@/components/desktop-sidebar-layout"; } from "@/components/desktop-sidebar-layout";
import { isNative, isWeb } from "@/constants/platform"; import { isNative, isWeb } from "@/constants/platform";
@@ -95,7 +101,12 @@ import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { WindowChromeProvider, WindowChromeRegion } from "@/utils/desktop-window"; import {
useHasWindowChromeObstruction,
WindowChromeProvider,
WindowChromeRegion,
WindowChromeSafeArea,
} from "@/utils/desktop-window";
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes"; import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
@@ -403,6 +414,7 @@ interface AppContainerProps {
} }
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"]; const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) { function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
const daemons = useHosts(); const daemons = useHosts();
@@ -480,7 +492,12 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
requestedSidebarWidth: sidebarWidth, requestedSidebarWidth: sidebarWidth,
viewportWidth, viewportWidth,
}); });
const contentWindowChromeCorners = desktopSidebarRendered ? "top-right" : "both"; const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
const appChromeLayout = resolveDesktopAppChromeLayout({
desktopSidebarRendered,
hasTopLeftWindowControls,
sidebarControlsEnabled: chromeEnabled && !isFocusModeEnabled,
});
const sidebarChrome = ( const sidebarChrome = (
<SidebarChrome <SidebarChrome
showSidebar={isCompactLayout ? chromeEnabled : desktopSidebarRendered} showSidebar={isCompactLayout ? chromeEnabled : desktopSidebarRendered}
@@ -490,7 +507,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const workspaceChrome = ( const workspaceChrome = (
<View style={rowStyle}> <View style={rowStyle}>
{!isCompactLayout ? ( {!isCompactLayout ? (
<WindowChromeRegion corners={desktopSidebarRendered ? "top-left" : "none"}> <WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
{sidebarChrome} {sidebarChrome}
</WindowChromeRegion> </WindowChromeRegion>
) : null} ) : null}
@@ -501,7 +518,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
</WindowChromeRegion> </WindowChromeRegion>
</CompactExplorerSidebarHost> </CompactExplorerSidebarHost>
) : ( ) : (
<WindowChromeRegion corners={contentWindowChromeCorners}> <WindowChromeRegion corners={appChromeLayout.contentCorners}>
<View style={flexStyle}>{children}</View> <View style={flexStyle}>{children}</View>
</WindowChromeRegion> </WindowChromeRegion>
)} )}
@@ -511,6 +528,18 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const surface = ( const surface = (
<View style={layoutStyles.surfaceFill}> <View style={layoutStyles.surfaceFill}>
{workspaceChrome} {workspaceChrome}
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left">
<WindowChromeSafeArea
placement="inline"
horizontalPadding={WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING}
pointerEvents="box-none"
style={layoutStyles.windowSidebarToggle}
>
<WindowSidebarMenuToggle />
</WindowChromeSafeArea>
</WindowChromeRegion>
) : null}
<FloatingPanelPortalHost /> <FloatingPanelPortalHost />
{isCompactLayout ? sidebarChrome : null} {isCompactLayout ? sidebarChrome : null}
<DownloadToast /> <DownloadToast />
@@ -943,4 +972,15 @@ const layoutStyles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
}, },
windowSidebarToggle: {
position: "absolute",
top: 0,
left: 0,
zIndex: 20,
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: "transparent",
},
})); }));
@@ -1,12 +1,59 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
canDesktopAppSidebarShare, canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum, resolveDesktopAppContentMinimum,
resolveDesktopExplorerWidth, resolveDesktopExplorerWidth,
resolveDesktopSidebarWidth, resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout"; } from "@/components/desktop-sidebar-layout";
describe("desktop sidebar layout", () => { describe("desktop sidebar layout", () => {
it("keeps the sidebar toggle window-owned beside left window controls", () => {
expect(
resolveDesktopAppChromeLayout({
desktopSidebarRendered: true,
hasTopLeftWindowControls: true,
sidebarControlsEnabled: true,
}),
).toEqual({
sidebarCorners: "top-left",
contentCorners: "top-right",
sidebarToggleOwner: "window",
});
expect(
resolveDesktopAppChromeLayout({
desktopSidebarRendered: true,
hasTopLeftWindowControls: false,
sidebarControlsEnabled: true,
}),
).toEqual({
sidebarCorners: "none",
contentCorners: "both",
sidebarToggleOwner: "content",
});
expect(
resolveDesktopAppChromeLayout({
desktopSidebarRendered: false,
hasTopLeftWindowControls: true,
sidebarControlsEnabled: true,
}),
).toEqual({
sidebarCorners: "none",
contentCorners: "both",
sidebarToggleOwner: "window",
});
});
it("hides the window-owned sidebar toggle when app chrome is suppressed", () => {
expect(
resolveDesktopAppChromeLayout({
desktopSidebarRendered: false,
hasTopLeftWindowControls: true,
sidebarControlsEnabled: false,
}).sidebarToggleOwner,
).toBe("none");
});
it("clamps a persisted wide sidebar to preserve the center pane", () => { it("clamps a persisted wide sidebar to preserve the center pane", () => {
const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 }); const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 });
expect(atHalfScreen).toBe(351); expect(atHalfScreen).toBe(351);
@@ -8,6 +8,23 @@ import {
export const MIN_DESKTOP_CENTER_WIDTH = 400; export const MIN_DESKTOP_CENTER_WIDTH = 400;
export function resolveDesktopAppChromeLayout(input: {
desktopSidebarRendered: boolean;
hasTopLeftWindowControls: boolean;
sidebarControlsEnabled: boolean;
}) {
const sidebarOwnsTopLeft = input.desktopSidebarRendered && input.hasTopLeftWindowControls;
let sidebarToggleOwner: "none" | "window" | "content" = "none";
if (input.sidebarControlsEnabled) {
sidebarToggleOwner = input.hasTopLeftWindowControls ? "window" : "content";
}
return {
sidebarCorners: sidebarOwnsTopLeft ? ("top-left" as const) : ("none" as const),
contentCorners: sidebarOwnsTopLeft ? ("top-right" as const) : ("both" as const),
sidebarToggleOwner,
};
}
function resolveDesktopPanelWidth(input: { function resolveDesktopPanelWidth(input: {
requestedWidth: number; requestedWidth: number;
viewportWidth: number; viewportWidth: number;
@@ -30,7 +30,7 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "@/git/diff-pane"; import { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane"; import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { RetainedPanelActivity } from "@/components/retained-panel"; import { RetainedPanelActivity } from "@/components/retained-panel";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -130,7 +130,6 @@ export function CompactExplorerSidebar({
workspaceId={workspaceId} workspaceId={workspaceId}
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
isGit={isGit} isGit={isGit}
isMobile
isOpen={isOpen} isOpen={isOpen}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
@@ -225,7 +224,6 @@ export function ExplorerSidebar({
workspaceId={workspaceId} workspaceId={workspaceId}
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
isGit={isGit} isGit={isGit}
isMobile={false}
isOpen={isOpen} isOpen={isOpen}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
@@ -270,7 +268,6 @@ interface SidebarContentProps {
workspaceId?: string | null; workspaceId?: string | null;
workspaceRoot: string; workspaceRoot: string;
isGit: boolean; isGit: boolean;
isMobile: boolean;
isOpen: boolean; isOpen: boolean;
onOpenFile?: (filePath: string) => void; onOpenFile?: (filePath: string) => void;
} }
@@ -283,13 +280,13 @@ function ExplorerSidebarContent({
workspaceId, workspaceId,
workspaceRoot, workspaceRoot,
isGit, isGit,
isMobile,
isOpen, isOpen,
onOpenFile, onOpenFile,
}: SidebarContentProps) { }: SidebarContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
const canQueryPullRequest = isGit && Boolean(workspaceRoot); const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({ const prPane = usePrPaneData({
serverId, serverId,
@@ -359,9 +356,25 @@ function ExplorerSidebarContent({
)} )}
</View> </View>
<View style={styles.headerRightSection}> <View style={styles.headerRightSection}>
{isMobile && ( {!hasRightWindowControls && (
<Pressable onPress={onClose} style={styles.closeButton}> <Pressable
<X size={18} color={theme.colors.foregroundMuted} /> onPress={onClose}
style={styles.closeButton}
testID="explorer-close"
nativeID="explorer-close"
accessible
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.explorer.close")}
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={18}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable> </Pressable>
)} )}
</View> </View>
@@ -5,10 +5,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native"; import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header"; import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title"; 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 { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
interface MenuHeaderProps { interface MenuHeaderProps {
title?: string; title?: string;
@@ -42,15 +43,18 @@ function MobileMenuIcon({ color }: { color: string }) {
); );
} }
export function SidebarMenuToggle({ function SidebarMenuToggleButton({
style, isMobile,
resolvedStyle,
tooltipSide = "right", tooltipSide = "right",
testID = "menu-button", testID = "menu-button",
nativeID = "menu-button", nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) { }: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile })); const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout); const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleShortcutKeys = useMemo( const toggleShortcutKeys = useMemo(
@@ -58,9 +62,6 @@ export function SidebarMenuToggle({
[], [],
); );
const menuIconColor =
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
toggleAgentListForLayout({ isCompact: isMobile }); toggleAgentListForLayout({ isCompact: isMobile });
}, [toggleAgentListForLayout, isMobile]); }, [toggleAgentListForLayout, isMobile]);
@@ -75,21 +76,54 @@ export function SidebarMenuToggle({
tooltipSide={tooltipSide} tooltipSide={tooltipSide}
testID={testID} testID={testID}
nativeID={nativeID} nativeID={nativeID}
style={style} style={resolvedStyle}
accessible accessible
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")} accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState} accessibilityState={accessibilityState}
> >
{isMobile ? ( {({ hovered, pressed }) => {
<MobileMenuIcon color={menuIconColor} /> const color = hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
) : ( return isMobile ? (
<PanelLeft size={theme.iconSize.md} color={menuIconColor} /> <MobileMenuIcon color={color} />
)} ) : (
<PanelLeft size={theme.iconSize.md} color={color} />
);
}}
</HeaderToggleButton> </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) { export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
return ( return (
<ScreenHeader <ScreenHeader
@@ -107,6 +141,12 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
leadingToggle: {
marginLeft: {
xs: 0,
md: -theme.spacing[2],
},
},
left: { left: {
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
@@ -116,6 +156,10 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "flex-start", alignItems: "flex-start",
}, },
desktopMenuIconSpace: {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
mobileMenuLine: { mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH, width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT, height: MOBILE_MENU_LINE_HEIGHT,
@@ -38,7 +38,7 @@ export function ScreenHeader({
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets // 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 topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2]; const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3];
const innerStyle = useMemo( const innerStyle = useMemo(
() => [styles.inner, { paddingTop: insets.top + topPadding }], () => [styles.inner, { paddingTop: insets.top + topPadding }],
+26 -5
View File
@@ -32,7 +32,7 @@ import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-disp
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -52,7 +52,7 @@ import { useHosts } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspace } from "@/stores/session-store-hooks";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { import {
@@ -706,6 +706,7 @@ function DesktopSidebar({
handleViewMore, handleViewMore,
handleViewSchedules, handleViewSchedules,
}: DesktopSidebarProps) { }: DesktopSidebarProps) {
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const pathname = usePathname(); const pathname = usePathname();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const isSessionsActive = pathname.includes("/sessions"); const isSessionsActive = pathname.includes("/sessions");
@@ -759,6 +760,10 @@ function DesktopSidebar({
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }], () => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop], [insetsTop],
); );
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
[ownsTopLeft],
);
const resizeHandleStyle = useMemo( const resizeHandleStyle = useMemo(
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)], () => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
[], [],
@@ -772,9 +777,14 @@ function DesktopSidebar({
<Animated.View style={desktopSidebarStyle}> <Animated.View style={desktopSidebarStyle}>
<View style={desktopSidebarBorderStyle}> <View style={desktopSidebarBorderStyle}>
<View style={styles.sidebarDragArea}> <View style={styles.sidebarDragArea}>
<TitlebarDragRegion /> {ownsTopLeft ? (
<WindowChromeSafeArea placement="below" /> <View style={styles.desktopChromeRow}>
<View style={styles.sidebarHeaderGroup}> <TitlebarDragRegion />
</View>
) : (
<TitlebarDragRegion />
)}
<View style={sidebarHeaderGroupStyle}>
<SidebarNewWorkspaceHeaderRow <SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace} label={labels.newWorkspace}
testID="sidebar-global-new-workspace" testID="sidebar-global-new-workspace"
@@ -920,6 +930,9 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
}, },
sidebarHeaderGroupBelowChrome: {
paddingTop: 0,
},
workspacesSectionHeader: { workspacesSectionHeader: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -991,6 +1004,14 @@ const styles = StyleSheet.create((theme) => ({
sidebarDragArea: { sidebarDragArea: {
position: "relative", position: "relative",
}, },
desktopChromeRow: {
position: "relative",
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: "transparent",
},
sidebarFooter: { sidebarFooter: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
intersectWindowChromeCorners, intersectWindowChromeCorners,
resolveHasOwnedWindowChromeObstruction,
resolveWindowChromeObstruction, resolveWindowChromeObstruction,
resolveWindowChromeSafeArea, resolveWindowChromeSafeArea,
} from "@/utils/desktop-window"; } from "@/utils/desktop-window";
@@ -49,4 +50,33 @@ describe("window chrome", () => {
expect(intersectWindowChromeCorners("top-right", "both")).toBe("top-right"); expect(intersectWindowChromeCorners("top-right", "both")).toBe("top-right");
expect(intersectWindowChromeCorners("top-left", "top-right")).toBe("none"); expect(intersectWindowChromeCorners("top-left", "top-right")).toBe("none");
}); });
it("reports an obstruction only when the surface owns its corner", () => {
const obstruction = {
topLeft: { width: 78, height: 45 },
topRight: { width: 140, height: 48 },
};
expect(
resolveHasOwnedWindowChromeObstruction({
obstruction,
corners: "top-left",
corner: "top-left",
}),
).toBe(true);
expect(
resolveHasOwnedWindowChromeObstruction({
obstruction,
corners: "top-left",
corner: "top-right",
}),
).toBe(false);
expect(
resolveHasOwnedWindowChromeObstruction({
obstruction: { topLeft: null, topRight: null },
corners: "both",
corner: "top-right",
}),
).toBe(false);
});
}); });
+36
View File
@@ -24,6 +24,8 @@ interface WindowChromeObstruction {
topRight: WindowChromeCornerObstruction | null; topRight: WindowChromeCornerObstruction | null;
} }
export type WindowChromeCorner = "top-left" | "top-right";
type WindowChromeSafeAreaStyle = { height: number } | { paddingLeft: number; paddingRight: number }; type WindowChromeSafeAreaStyle = { height: number } | { paddingLeft: number; paddingRight: number };
const EMPTY_OBSTRUCTION: WindowChromeObstruction = { topLeft: null, topRight: null }; const EMPTY_OBSTRUCTION: WindowChromeObstruction = { topLeft: null, topRight: null };
@@ -37,6 +39,29 @@ function windowChromeCornersFromFlags(topLeft: boolean, topRight: boolean): Wind
return "none"; return "none";
} }
export function windowChromeCornersInclude(
corners: WindowChromeCorners,
corner: WindowChromeCorner,
): boolean {
return corners === "both" || corners === corner;
}
export function resolveHasOwnedWindowChromeObstruction(input: {
obstruction: WindowChromeObstruction;
corners: WindowChromeCorners;
corner: WindowChromeCorner;
}): boolean {
if (!windowChromeCornersInclude(input.corners, input.corner)) return false;
return input.corner === "top-left"
? input.obstruction.topLeft !== null
: input.obstruction.topRight !== null;
}
export function useHasWindowChromeObstruction(corner: WindowChromeCorner): boolean {
const obstruction = useContext(WindowChromeContext);
return corner === "top-left" ? obstruction.topLeft !== null : obstruction.topRight !== null;
}
export function intersectWindowChromeCorners( export function intersectWindowChromeCorners(
inherited: WindowChromeCorners, inherited: WindowChromeCorners,
declared: WindowChromeCorners, declared: WindowChromeCorners,
@@ -210,6 +235,17 @@ export function useWindowChromeCorners(): WindowChromeCorners {
return useContext(WindowChromeCornersContext); return useContext(WindowChromeCornersContext);
} }
export function useOwnsWindowChromeCorner(corner: WindowChromeCorner): boolean {
const corners = useContext(WindowChromeCornersContext);
return windowChromeCornersInclude(corners, corner);
}
export function useHasOwnedWindowChromeObstruction(corner: WindowChromeCorner): boolean {
const obstruction = useContext(WindowChromeContext);
const corners = useContext(WindowChromeCornersContext);
return resolveHasOwnedWindowChromeObstruction({ obstruction, corners, corner });
}
type WindowChromeSafeAreaProps = ViewProps & { type WindowChromeSafeAreaProps = ViewProps & {
placement: WindowChromeSafeAreaPlacement; placement: WindowChromeSafeAreaPlacement;
horizontalPadding?: number; horizontalPadding?: number;