mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -175,6 +175,24 @@ test.describe("Half-screen desktop layout", () => {
|
||||
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 }) => {
|
||||
await gotoAppShell(page);
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
@@ -196,13 +214,40 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(
|
||||
page.getByTestId("explorer-tab-files").filter({ visible: true }).first(),
|
||||
).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();
|
||||
|
||||
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
|
||||
.poll(
|
||||
async () =>
|
||||
(await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0,
|
||||
)
|
||||
.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 {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
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 { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
getIsElectronRuntime,
|
||||
HEADER_INNER_HEIGHT,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppChromeLayout,
|
||||
resolveDesktopAppContentMinimum,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
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 type { HostProfile } from "@/types/host-connection";
|
||||
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 { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
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 WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
|
||||
|
||||
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
|
||||
const daemons = useHosts();
|
||||
@@ -480,7 +492,12 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
requestedSidebarWidth: sidebarWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
const contentWindowChromeCorners = desktopSidebarRendered ? "top-right" : "both";
|
||||
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
|
||||
const appChromeLayout = resolveDesktopAppChromeLayout({
|
||||
desktopSidebarRendered,
|
||||
hasTopLeftWindowControls,
|
||||
sidebarControlsEnabled: chromeEnabled && !isFocusModeEnabled,
|
||||
});
|
||||
const sidebarChrome = (
|
||||
<SidebarChrome
|
||||
showSidebar={isCompactLayout ? chromeEnabled : desktopSidebarRendered}
|
||||
@@ -490,7 +507,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={desktopSidebarRendered ? "top-left" : "none"}>
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{sidebarChrome}
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
@@ -501,7 +518,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
</WindowChromeRegion>
|
||||
</CompactExplorerSidebarHost>
|
||||
) : (
|
||||
<WindowChromeRegion corners={contentWindowChromeCorners}>
|
||||
<WindowChromeRegion corners={appChromeLayout.contentCorners}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</WindowChromeRegion>
|
||||
)}
|
||||
@@ -511,6 +528,18 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const surface = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{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 />
|
||||
{isCompactLayout ? sidebarChrome : null}
|
||||
<DownloadToast />
|
||||
@@ -943,4 +972,15 @@ const layoutStyles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
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 {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppChromeLayout,
|
||||
resolveDesktopAppContentMinimum,
|
||||
resolveDesktopExplorerWidth,
|
||||
resolveDesktopSidebarWidth,
|
||||
} from "@/components/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", () => {
|
||||
const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 });
|
||||
expect(atHalfScreen).toBe(351);
|
||||
|
||||
@@ -8,6 +8,23 @@ import {
|
||||
|
||||
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: {
|
||||
requestedWidth: number;
|
||||
viewportWidth: number;
|
||||
|
||||
@@ -30,7 +30,7 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
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 { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -130,7 +130,6 @@ export function CompactExplorerSidebar({
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
@@ -225,7 +224,6 @@ export function ExplorerSidebar({
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
@@ -270,7 +268,6 @@ interface SidebarContentProps {
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
isMobile: boolean;
|
||||
isOpen: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
@@ -283,13 +280,13 @@ function ExplorerSidebarContent({
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
isMobile,
|
||||
isOpen,
|
||||
onOpenFile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
serverId,
|
||||
@@ -359,9 +356,25 @@ function ExplorerSidebarContent({
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.headerRightSection}>
|
||||
{isMobile && (
|
||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||
<X size={18} color={theme.colors.foregroundMuted} />
|
||||
{!hasRightWindowControls && (
|
||||
<Pressable
|
||||
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>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -32,7 +32,7 @@ import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-disp
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
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 { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
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 { useWorkspace } from "@/stores/session-store-hooks";
|
||||
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 { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
@@ -706,6 +706,7 @@ function DesktopSidebar({
|
||||
handleViewMore,
|
||||
handleViewSchedules,
|
||||
}: DesktopSidebarProps) {
|
||||
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
|
||||
const pathname = usePathname();
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
@@ -759,6 +760,10 @@ function DesktopSidebar({
|
||||
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
|
||||
[insetsTop],
|
||||
);
|
||||
const sidebarHeaderGroupStyle = useMemo(
|
||||
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
|
||||
[ownsTopLeft],
|
||||
);
|
||||
const resizeHandleStyle = useMemo(
|
||||
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
||||
[],
|
||||
@@ -772,9 +777,14 @@ function DesktopSidebar({
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
{ownsTopLeft ? (
|
||||
<View style={styles.desktopChromeRow}>
|
||||
<TitlebarDragRegion />
|
||||
</View>
|
||||
) : (
|
||||
<TitlebarDragRegion />
|
||||
)}
|
||||
<View style={sidebarHeaderGroupStyle}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
@@ -920,6 +930,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
sidebarHeaderGroupBelowChrome: {
|
||||
paddingTop: 0,
|
||||
},
|
||||
workspacesSectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -991,6 +1004,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
desktopChromeRow: {
|
||||
position: "relative",
|
||||
height: HEADER_INNER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: "transparent",
|
||||
},
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
intersectWindowChromeCorners,
|
||||
resolveHasOwnedWindowChromeObstruction,
|
||||
resolveWindowChromeObstruction,
|
||||
resolveWindowChromeSafeArea,
|
||||
} from "@/utils/desktop-window";
|
||||
@@ -49,4 +50,33 @@ describe("window chrome", () => {
|
||||
expect(intersectWindowChromeCorners("top-right", "both")).toBe("top-right");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ interface WindowChromeObstruction {
|
||||
topRight: WindowChromeCornerObstruction | null;
|
||||
}
|
||||
|
||||
export type WindowChromeCorner = "top-left" | "top-right";
|
||||
|
||||
type WindowChromeSafeAreaStyle = { height: number } | { paddingLeft: number; paddingRight: number };
|
||||
|
||||
const EMPTY_OBSTRUCTION: WindowChromeObstruction = { topLeft: null, topRight: null };
|
||||
@@ -37,6 +39,29 @@ function windowChromeCornersFromFlags(topLeft: boolean, topRight: boolean): Wind
|
||||
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(
|
||||
inherited: WindowChromeCorners,
|
||||
declared: WindowChromeCorners,
|
||||
@@ -210,6 +235,17 @@ export function useWindowChromeCorners(): WindowChromeCorners {
|
||||
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 & {
|
||||
placement: WindowChromeSafeAreaPlacement;
|
||||
horizontalPadding?: number;
|
||||
|
||||
Reference in New Issue
Block a user