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();
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user