Fix desktop layout and window controls at half-screen width (#1983)
* fix(app): preserve desktop layout in half-screen windows Treat native window controls as physical corner obstructions instead of deriving clearance from compact layout state. Keep 751px windows in desktop mode while preserving usable content when persisted sidebars are wide. * fix(app): retain sidebar width across window resizes * fix(app): yield app sidebar to narrow settings * test(app): wait for mobile sidebar motion to settle * fix(app): yield navigation to narrow workspace explorer * fix(app): mark sidebar sizing helpers as worklets * fix(app): hide app navigation when space is constrained
This commit is contained in:
@@ -16,7 +16,7 @@ import {
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useWindowDimensions, View } from "react-native";
|
||||
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
@@ -38,6 +38,10 @@ import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-short
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppContentMinimum,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
@@ -91,6 +95,7 @@ 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 { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
@@ -409,6 +414,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||
@@ -454,22 +464,46 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
useActiveWorktreeNewAction();
|
||||
useGlobalNewWorkspaceAction();
|
||||
|
||||
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: pathname.includes("/settings"),
|
||||
isWorkspaceExplorerOpen: pathname.includes("/workspace/") && isDesktopFileExplorerOpen,
|
||||
requestedExplorerWidth: explorerWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
const desktopSidebarRendered =
|
||||
!isCompactLayout &&
|
||||
chromeEnabled &&
|
||||
!isFocusModeEnabled &&
|
||||
isDesktopAgentListOpen &&
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth: appContentMinimumWidth,
|
||||
requestedSidebarWidth: sidebarWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
const contentWindowChromeCorners = desktopSidebarRendered ? "top-right" : "both";
|
||||
const sidebarChrome = (
|
||||
<SidebarChrome
|
||||
showSidebar={chromeEnabled && (isCompactLayout || !isFocusModeEnabled)}
|
||||
showSidebar={isCompactLayout ? chromeEnabled : desktopSidebarRendered}
|
||||
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout ? sidebarChrome : null}
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={desktopSidebarRendered ? "top-left" : "none"}>
|
||||
{sidebarChrome}
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
{isCompactLayout && chromeEnabled ? (
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
<WindowChromeRegion corners="both">
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</WindowChromeRegion>
|
||||
</CompactExplorerSidebarHost>
|
||||
) : (
|
||||
<View style={flexStyle}>{children}</View>
|
||||
<WindowChromeRegion corners={contentWindowChromeCorners}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</WindowChromeRegion>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -863,13 +897,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
<WindowChromeProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</WindowChromeProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { WindowChromeRootRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
|
||||
interface AttachmentLightboxProps {
|
||||
metadata: AttachmentMetadata | null;
|
||||
@@ -38,15 +39,18 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
|
||||
};
|
||||
}, [metadata, onClose]);
|
||||
|
||||
const closeButtonStyle = useMemo(
|
||||
const closeButtonRowStyle = useMemo(
|
||||
() => [
|
||||
styles.closeButton,
|
||||
styles.closeButtonRow,
|
||||
{
|
||||
top: insets.top + theme.spacing[3],
|
||||
right: insets.right + theme.spacing[3],
|
||||
},
|
||||
],
|
||||
[insets.top, insets.right, theme.spacing],
|
||||
[insets.top, theme.spacing],
|
||||
);
|
||||
const closeButtonStyle = useMemo(
|
||||
() => [styles.closeButton, { marginRight: insets.right + theme.spacing[3] }],
|
||||
[insets.right, theme.spacing],
|
||||
);
|
||||
|
||||
const handleImageError = useCallback(() => setErrored(true), []);
|
||||
@@ -61,42 +65,46 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<View style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View style={styles.contentLayer}>
|
||||
<View style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<Pressable onPress={noopPress} style={styles.imagePressable}>
|
||||
<ExpoImage
|
||||
testID="attachment-lightbox-image"
|
||||
source={imageSource}
|
||||
contentFit="contain"
|
||||
onError={handleImageError}
|
||||
style={imageFillStyle}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-close"
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.closeImage")}
|
||||
hitSlop={8}
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={closeButtonStyle}
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View style={styles.contentLayer}>
|
||||
<View style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<Pressable onPress={noopPress} style={styles.imagePressable}>
|
||||
<ExpoImage
|
||||
testID="attachment-lightbox-image"
|
||||
source={imageSource}
|
||||
contentFit="contain"
|
||||
onError={handleImageError}
|
||||
style={imageFillStyle}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<WindowChromeSafeArea placement="inline" style={closeButtonRowStyle}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-close"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.closeImage")}
|
||||
hitSlop={8}
|
||||
onPress={onClose}
|
||||
style={closeButtonStyle}
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</WindowChromeSafeArea>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -129,6 +137,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
bottom: 0,
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
closeButtonRow: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "flex-end",
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
imageArea: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
@@ -148,7 +163,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppContentMinimum,
|
||||
resolveDesktopExplorerWidth,
|
||||
resolveDesktopSidebarWidth,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
|
||||
describe("desktop sidebar layout", () => {
|
||||
it("clamps a persisted wide sidebar to preserve the center pane", () => {
|
||||
const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 });
|
||||
expect(atHalfScreen).toBe(351);
|
||||
expect(751 - atHalfScreen).toBe(400);
|
||||
|
||||
const atBreakpoint = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 720 });
|
||||
expect(atBreakpoint).toBe(320);
|
||||
expect(720 - atBreakpoint).toBe(400);
|
||||
|
||||
expect(resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 1440 })).toBe(600);
|
||||
});
|
||||
|
||||
it("keeps a temporarily narrow explorer render-only", () => {
|
||||
expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 751 })).toBe(351);
|
||||
expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 1440 })).toBe(400);
|
||||
});
|
||||
|
||||
it("yields app navigation when settings or Explorer need the shell width", () => {
|
||||
const settingsMinimum = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: true,
|
||||
isWorkspaceExplorerOpen: false,
|
||||
requestedExplorerWidth: 400,
|
||||
viewportWidth: 751,
|
||||
});
|
||||
expect(settingsMinimum).toBe(720);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth: settingsMinimum,
|
||||
requestedSidebarWidth: 320,
|
||||
viewportWidth: 751,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
const explorerMinimum = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: false,
|
||||
isWorkspaceExplorerOpen: true,
|
||||
requestedExplorerWidth: 400,
|
||||
viewportWidth: 751,
|
||||
});
|
||||
expect(explorerMinimum).toBe(751);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth: explorerMinimum,
|
||||
requestedSidebarWidth: 320,
|
||||
viewportWidth: 751,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth: resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: false,
|
||||
isWorkspaceExplorerOpen: true,
|
||||
requestedExplorerWidth: 400,
|
||||
viewportWidth: 1120,
|
||||
}),
|
||||
requestedSidebarWidth: 320,
|
||||
viewportWidth: 1120,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
|
||||
import {
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
} from "@/stores/panel-store";
|
||||
|
||||
export const MIN_DESKTOP_CENTER_WIDTH = 400;
|
||||
|
||||
function resolveDesktopPanelWidth(input: {
|
||||
requestedWidth: number;
|
||||
viewportWidth: number;
|
||||
minimumWidth: number;
|
||||
maximumWidth: number;
|
||||
}): number {
|
||||
"worklet";
|
||||
const maximumVisibleWidth = Math.max(
|
||||
input.minimumWidth,
|
||||
Math.min(input.maximumWidth, input.viewportWidth - MIN_DESKTOP_CENTER_WIDTH),
|
||||
);
|
||||
return Math.max(input.minimumWidth, Math.min(maximumVisibleWidth, input.requestedWidth));
|
||||
}
|
||||
|
||||
export function resolveDesktopSidebarWidth(input: {
|
||||
requestedWidth: number;
|
||||
viewportWidth: number;
|
||||
}): number {
|
||||
"worklet";
|
||||
return resolveDesktopPanelWidth({
|
||||
...input,
|
||||
minimumWidth: MIN_SIDEBAR_WIDTH,
|
||||
maximumWidth: MAX_SIDEBAR_WIDTH,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDesktopExplorerWidth(input: {
|
||||
requestedWidth: number;
|
||||
viewportWidth: number;
|
||||
}): number {
|
||||
"worklet";
|
||||
return resolveDesktopPanelWidth({
|
||||
...input,
|
||||
minimumWidth: MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
maximumWidth: MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDesktopAppContentMinimum(input: {
|
||||
isSettingsRoute: boolean;
|
||||
isWorkspaceExplorerOpen: boolean;
|
||||
requestedExplorerWidth: number;
|
||||
viewportWidth: number;
|
||||
}): number {
|
||||
const workspaceMinimum = input.isWorkspaceExplorerOpen
|
||||
? MIN_DESKTOP_CENTER_WIDTH +
|
||||
resolveDesktopExplorerWidth({
|
||||
requestedWidth: input.requestedExplorerWidth,
|
||||
viewportWidth: input.viewportWidth,
|
||||
})
|
||||
: 0;
|
||||
return Math.max(input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0, workspaceMinimum);
|
||||
}
|
||||
|
||||
export function canDesktopAppSidebarShare(input: {
|
||||
contentMinimumWidth: number;
|
||||
requestedSidebarWidth: number;
|
||||
viewportWidth: number;
|
||||
}): boolean {
|
||||
return (
|
||||
input.viewportWidth -
|
||||
resolveDesktopSidebarWidth({
|
||||
requestedWidth: input.requestedSidebarWidth,
|
||||
viewportWidth: input.viewportWidth,
|
||||
}) >=
|
||||
input.contentMinimumWidth
|
||||
);
|
||||
}
|
||||
@@ -22,13 +22,7 @@ import {
|
||||
} from "@/git/pull-request-panel";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsFileExplorerOpen,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
@@ -36,13 +30,13 @@ 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 { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
|
||||
interface ExplorerSidebarProps {
|
||||
@@ -163,18 +157,16 @@ export function ExplorerSidebar({
|
||||
isGit,
|
||||
});
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const startWidthRef = useRef(explorerWidth);
|
||||
const resizeWidth = useSharedValue(explorerWidth);
|
||||
const visibleExplorerWidth = resolveDesktopExplorerWidth({
|
||||
requestedWidth: explorerWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
const startWidthRef = useRef(visibleExplorerWidth);
|
||||
const resizeWidth = useSharedValue(visibleExplorerWidth);
|
||||
|
||||
useEffect(() => {
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
if (explorerWidth > maxWidth) {
|
||||
setExplorerWidth(maxWidth);
|
||||
}
|
||||
}, [explorerWidth, setExplorerWidth, viewportWidth]);
|
||||
resizeWidth.value = visibleExplorerWidth;
|
||||
}, [resizeWidth, visibleExplorerWidth]);
|
||||
|
||||
const handleDesktopClose = useCallback(() => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
@@ -190,22 +182,20 @@ export function ExplorerSidebar({
|
||||
.enabled(true)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
startWidthRef.current = explorerWidth;
|
||||
resizeWidth.value = explorerWidth;
|
||||
startWidthRef.current = visibleExplorerWidth;
|
||||
resizeWidth.value = visibleExplorerWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const newWidth = startWidthRef.current - event.translationX;
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
||||
resizeWidth.value = clampedWidth;
|
||||
resizeWidth.value = resolveDesktopExplorerWidth({
|
||||
requestedWidth: newWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||
}),
|
||||
[explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
|
||||
[resizeWidth, setExplorerWidth, viewportWidth, visibleExplorerWidth],
|
||||
);
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
@@ -300,7 +290,6 @@ function ExplorerSidebarContent({
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const padding = useWindowControlsPadding("explorerSidebar");
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
serverId,
|
||||
@@ -325,15 +314,15 @@ function ExplorerSidebarContent({
|
||||
[serverId, workspaceId, workspaceRoot],
|
||||
);
|
||||
|
||||
const headerStyle = useMemo(
|
||||
() => [styles.header, { paddingRight: padding.right }],
|
||||
[padding.right],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header with tabs and close button */}
|
||||
<View style={headerStyle} testID="explorer-header">
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
horizontalPadding={theme.spacing[2]}
|
||||
style={styles.header}
|
||||
testID="explorer-header"
|
||||
>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.tabsContainer}>
|
||||
{isGit && (
|
||||
@@ -376,7 +365,7 @@ function ExplorerSidebarContent({
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
@@ -476,7 +465,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
@@ -18,7 +18,6 @@ interface ScreenHeaderProps {
|
||||
leftStyle?: StyleProp<ViewStyle>;
|
||||
rightStyle?: StyleProp<ViewStyle>;
|
||||
borderless?: boolean;
|
||||
windowControlsPaddingRole?: "header" | "detailHeader";
|
||||
onRowLayout?: (event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
@@ -32,13 +31,11 @@ export function ScreenHeader({
|
||||
leftStyle,
|
||||
rightStyle,
|
||||
borderless,
|
||||
windowControlsPaddingRole = "header",
|
||||
onRowLayout,
|
||||
}: ScreenHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const padding = useWindowControlsPadding(windowControlsPaddingRole);
|
||||
// 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];
|
||||
@@ -47,28 +44,23 @@ export function ScreenHeader({
|
||||
() => [styles.inner, { paddingTop: insets.top + topPadding }],
|
||||
[insets.top, topPadding],
|
||||
);
|
||||
const rowStyle = useMemo(
|
||||
() => [
|
||||
styles.row,
|
||||
{
|
||||
paddingLeft: baseHorizontalPadding + padding.left,
|
||||
paddingRight: baseHorizontalPadding + padding.right,
|
||||
},
|
||||
borderless && styles.borderless,
|
||||
],
|
||||
[baseHorizontalPadding, padding.left, padding.right, borderless],
|
||||
);
|
||||
const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]);
|
||||
const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]);
|
||||
const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={innerStyle}>
|
||||
<View onLayout={onRowLayout} style={rowStyle}>
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
horizontalPadding={baseHorizontalPadding}
|
||||
onLayout={onRowLayout}
|
||||
style={rowStyle}
|
||||
>
|
||||
<TitlebarDragRegion />
|
||||
<View style={leftCombinedStyle}>{left}</View>
|
||||
<View style={rightCombinedStyle}>{right}</View>
|
||||
</View>
|
||||
</WindowChromeSafeArea>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -88,7 +80,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
userSelect: "none",
|
||||
|
||||
@@ -25,6 +25,7 @@ import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-nativ
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
|
||||
import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
|
||||
@@ -50,13 +51,8 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
selectIsAgentListOpen,
|
||||
usePanelStore,
|
||||
} from "@/stores/panel-store";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
@@ -73,8 +69,6 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
interface SidebarSharedProps {
|
||||
@@ -603,6 +597,7 @@ function MobileSidebar({
|
||||
panelStyle={mobileSidebarInsetStyle}
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
@@ -628,23 +623,25 @@ function MobileSidebar({
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -713,47 +710,47 @@ function DesktopSidebar({
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const visibleSidebarWidth = resolveDesktopSidebarWidth({
|
||||
requestedWidth: sidebarWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
|
||||
const startWidthRef = useRef(sidebarWidth);
|
||||
const resizeWidth = useSharedValue(sidebarWidth);
|
||||
const startWidthRef = useRef(visibleSidebarWidth);
|
||||
const resizeWidth = useSharedValue(visibleSidebarWidth);
|
||||
|
||||
useEffect(() => {
|
||||
resizeWidth.value = sidebarWidth;
|
||||
}, [sidebarWidth, resizeWidth]);
|
||||
resizeWidth.value = visibleSidebarWidth;
|
||||
}, [resizeWidth, visibleSidebarWidth]);
|
||||
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
startWidthRef.current = sidebarWidth;
|
||||
resizeWidth.value = sidebarWidth;
|
||||
startWidthRef.current = visibleSidebarWidth;
|
||||
resizeWidth.value = visibleSidebarWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Dragging right (positive translationX) increases width
|
||||
const newWidth = startWidthRef.current + event.translationX;
|
||||
const maxWidth = Math.max(
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
||||
resizeWidth.value = clampedWidth;
|
||||
resizeWidth.value = resolveDesktopSidebarWidth({
|
||||
requestedWidth: newWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setSidebarWidth)(resizeWidth.value);
|
||||
}),
|
||||
[sidebarWidth, resizeWidth, setSidebarWidth, viewportWidth],
|
||||
[resizeWidth, setSidebarWidth, viewportWidth, visibleSidebarWidth],
|
||||
);
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
|
||||
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
|
||||
[resizeAnimatedStyle],
|
||||
@@ -776,7 +773,7 @@ function DesktopSidebar({
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<View style={styles.sidebarHeaderGroup}>
|
||||
<SidebarNewWorkspaceHeaderRow
|
||||
label={labels.newWorkspace}
|
||||
@@ -960,11 +957,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
mobileCloseButton: {
|
||||
mobileCloseButtonRow: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[3],
|
||||
right: theme.spacing[4],
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
alignItems: "flex-end",
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
mobileCloseButton: {
|
||||
marginRight: theme.spacing[4],
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSplitContainerRoot } from "@/components/split-container-focus";
|
||||
import type { SplitNode } from "@/stores/workspace-layout-store";
|
||||
|
||||
const pane = (id: string): SplitNode => ({
|
||||
kind: "pane",
|
||||
pane: { id, tabIds: [], focusedTabId: null },
|
||||
});
|
||||
const root: SplitNode = {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "root",
|
||||
direction: "horizontal",
|
||||
children: [pane("left"), pane("right")],
|
||||
sizes: [0.5, 0.5],
|
||||
},
|
||||
};
|
||||
|
||||
describe("split focus root", () => {
|
||||
it("renders only the valid focused pane in focus mode", () => {
|
||||
expect(
|
||||
resolveSplitContainerRoot({ root, focusedPaneId: "right", focusModeEnabled: true }),
|
||||
).toEqual({ root: pane("right"), usesFallbackStrip: false });
|
||||
});
|
||||
|
||||
it("keeps the full tree and reserves the boundary strip when focus is missing", () => {
|
||||
expect(
|
||||
resolveSplitContainerRoot({ root, focusedPaneId: "missing", focusModeEnabled: true }),
|
||||
).toEqual({ root, usesFallbackStrip: true });
|
||||
});
|
||||
|
||||
it("keeps normal splits unclaimed", () => {
|
||||
expect(
|
||||
resolveSplitContainerRoot({ root, focusedPaneId: "right", focusModeEnabled: false }),
|
||||
).toEqual({ root, usesFallbackStrip: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SplitNode, SplitPane } from "@/stores/workspace-layout-store";
|
||||
|
||||
export function resolveSplitContainerRoot(input: {
|
||||
root: SplitNode;
|
||||
focusedPaneId: string | null;
|
||||
focusModeEnabled: boolean | undefined;
|
||||
}): { root: SplitNode; usesFallbackStrip: boolean } {
|
||||
if (!input.focusModeEnabled) return { root: input.root, usesFallbackStrip: false };
|
||||
const focusedPane = input.focusedPaneId ? findPane(input.root, input.focusedPaneId) : null;
|
||||
if (!focusedPane) return { root: input.root, usesFallbackStrip: true };
|
||||
return { root: { kind: "pane", pane: focusedPane }, usesFallbackStrip: false };
|
||||
}
|
||||
|
||||
function findPane(node: SplitNode, paneId: string): SplitPane | null {
|
||||
if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null;
|
||||
for (const child of node.group.children) {
|
||||
const pane = findPane(child, paneId);
|
||||
if (pane) return pane;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -32,8 +32,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { resolveSplitContainerRoot } from "@/components/split-container-focus";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import {
|
||||
WindowChromeRegion,
|
||||
WindowChromeSafeArea,
|
||||
useWindowChromeCorners,
|
||||
type WindowChromeCorners,
|
||||
} from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import {
|
||||
computeTabDropPreview,
|
||||
@@ -158,6 +164,7 @@ interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMov
|
||||
showDropZones: boolean;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
tabDropPreview: TabDropPreview | null;
|
||||
windowChromeCorners: WindowChromeCorners;
|
||||
}
|
||||
|
||||
interface SplitPaneViewProps extends Omit<
|
||||
@@ -169,6 +176,7 @@ interface SplitPaneViewProps extends Omit<
|
||||
| "showDropZones"
|
||||
| "dropPreview"
|
||||
| "onResizeSplit"
|
||||
| "windowChromeCorners"
|
||||
> {
|
||||
pane: SplitPane;
|
||||
uiTabs: WorkspaceTab[];
|
||||
@@ -383,6 +391,8 @@ export function SplitContainer({
|
||||
renderPaneEmptyState = () => null,
|
||||
focusModeEnabled,
|
||||
}: SplitContainerProps) {
|
||||
const inheritedWindowChromeCorners = useWindowChromeCorners();
|
||||
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
|
||||
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
|
||||
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
|
||||
const [tabDropPreview, setTabDropPreview] = useState<TabDropPreview | null>(null);
|
||||
@@ -399,18 +409,16 @@ export function SplitContainer({
|
||||
);
|
||||
|
||||
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
|
||||
|
||||
const effectiveRoot = useMemo(() => {
|
||||
if (!focusModeEnabled) {
|
||||
return layout.root;
|
||||
}
|
||||
const focusedPane = layout.focusedPaneId ? panesById.get(layout.focusedPaneId) : null;
|
||||
if (!focusedPane) {
|
||||
return layout.root;
|
||||
}
|
||||
return { kind: "pane" as const, pane: focusedPane };
|
||||
}, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]);
|
||||
const renderRoot = useMemo(() => wrapRootPaneForStableMount(effectiveRoot), [effectiveRoot]);
|
||||
const splitRoot = useMemo(
|
||||
() =>
|
||||
resolveSplitContainerRoot({
|
||||
root: layout.root,
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
focusModeEnabled,
|
||||
}),
|
||||
[focusModeEnabled, layout.focusedPaneId, layout.root],
|
||||
);
|
||||
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||
@@ -565,6 +573,7 @@ export function SplitContainer({
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
|
||||
<SplitNodeView
|
||||
node={renderRoot}
|
||||
workspaceKey={workspaceKey}
|
||||
@@ -601,6 +610,7 @@ export function SplitContainer({
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
|
||||
/>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragTabId ? (
|
||||
@@ -744,6 +754,7 @@ function SplitNodeView({
|
||||
showDropZones,
|
||||
dropPreview,
|
||||
tabDropPreview,
|
||||
windowChromeCorners,
|
||||
}: SplitNodeViewProps) {
|
||||
const groupId = node.kind === "group" ? node.group.id : null;
|
||||
const groupDirection = node.kind === "group" ? node.group.direction : null;
|
||||
@@ -762,41 +773,43 @@ function SplitNodeView({
|
||||
|
||||
if (node.kind === "pane") {
|
||||
return (
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
uiTabs={uiTabs}
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
/>
|
||||
<WindowChromeRegion corners={windowChromeCorners}>
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
uiTabs={uiTabs}
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
/>
|
||||
</WindowChromeRegion>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -843,6 +856,7 @@ function SplitNodeView({
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={windowChromeCorners}
|
||||
/>
|
||||
</SplitGroupChild>
|
||||
{index < node.group.children.length - 1 ? (
|
||||
@@ -898,7 +912,6 @@ function SplitPaneView({
|
||||
const { theme: _theme } = useUnistyles();
|
||||
const paneRef = useRef<View | null>(null);
|
||||
const stableOnFocusPane = useStableEvent(onFocusPane);
|
||||
const padding = useWindowControlsPadding("tabRow");
|
||||
const paneState = useMemo(
|
||||
() =>
|
||||
deriveWorkspacePaneState({
|
||||
@@ -995,15 +1008,11 @@ function SplitPaneView({
|
||||
() => onSplitPaneEmpty({ targetPaneId: paneId, position: "bottom" }),
|
||||
[onSplitPaneEmpty, paneId],
|
||||
);
|
||||
const paneTabsStyle = useMemo(
|
||||
() => [styles.paneTabs, { paddingLeft: padding.left, paddingRight: padding.right }],
|
||||
[padding.left, padding.right],
|
||||
);
|
||||
|
||||
return (
|
||||
<RenderProfile id={`SplitPaneView:${pane.id}`}>
|
||||
<View ref={paneRef} collapsable={false} style={styles.pane}>
|
||||
<View style={paneTabsStyle}>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
||||
<TitlebarDragRegion />
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
@@ -1035,7 +1044,7 @@ function SplitPaneView({
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
{mountedPaneTabIds.length > 0
|
||||
|
||||
@@ -15,6 +15,13 @@ export const HEADER_TOP_PADDING_MOBILE = 8;
|
||||
export const MAX_CONTENT_WIDTH = 820;
|
||||
export const COMPACT_FORM_FACTOR_WIDTH = 500;
|
||||
|
||||
// Settings uses the canonical desktop list + detail layout. Its sidebar and
|
||||
// detail target must fit together before it can share width with app navigation.
|
||||
export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320;
|
||||
export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400;
|
||||
export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH =
|
||||
SETTINGS_DESKTOP_SIDEBAR_WIDTH + SETTINGS_DESKTOP_DETAIL_MIN_WIDTH;
|
||||
|
||||
// Desktop app constants for macOS traffic light buttons
|
||||
// These buttons (close/minimize/maximize) overlay the top-left corner
|
||||
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface DesktopWindowControlsOverlayUpdate {
|
||||
export interface DesktopWindowBridge {
|
||||
label?: string;
|
||||
toggleMaximize?: () => Promise<void>;
|
||||
setFullscreen?: (fullscreen: boolean) => Promise<void>;
|
||||
isFullscreen?: () => Promise<boolean>;
|
||||
updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise<void>;
|
||||
onResized?: <TEvent = unknown>(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Pressable, StyleSheet, View } from "react-native";
|
||||
import { GestureDetector, type GestureType } from "react-native-gesture-handler";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
|
||||
import { getMobilePanelFrame } from "./model";
|
||||
import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider";
|
||||
@@ -82,7 +83,7 @@ export function MobilePanelOverlay({
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
|
||||
{children}
|
||||
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
|
||||
@@ -57,7 +57,7 @@ import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
@@ -97,7 +97,7 @@ import {
|
||||
} from "@/screens/settings/host-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import {
|
||||
type EnableBuiltInDaemonOption,
|
||||
@@ -992,7 +992,6 @@ function SettingsSidebar({
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
||||
const insets = useSafeAreaInsets();
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const isDesktop = layout === "desktop";
|
||||
const outerContainerStyle = useMemo(
|
||||
() => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer],
|
||||
@@ -1005,7 +1004,6 @@ function SettingsSidebar({
|
||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
||||
const selectedHostSection = view.kind === "host" ? view.section : null;
|
||||
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||
|
||||
const sidebarBody = (
|
||||
<>
|
||||
@@ -1087,7 +1085,7 @@ function SettingsSidebar({
|
||||
<View style={innerContainerStyle}>
|
||||
<View style={sidebarStyles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopStyle} /> : null}
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<SidebarHeaderRow
|
||||
icon={ArrowLeft}
|
||||
label={t("settings.backToWorkspace")}
|
||||
@@ -1434,6 +1432,16 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
);
|
||||
}
|
||||
|
||||
const desktopDetailHeaderLeft = detailHeader ? (
|
||||
<>
|
||||
<HeaderIconBadge>
|
||||
<detailHeader.Icon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</HeaderIconBadge>
|
||||
<ScreenTitle testID="settings-detail-header-title">{detailHeader.title}</ScreenTitle>
|
||||
{detailHeader.titleAccessory}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const addHostModals = (
|
||||
<>
|
||||
<AddHostMethodModal
|
||||
@@ -1508,43 +1516,31 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={desktopStyles.row}>
|
||||
<SettingsSidebar
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHostSection={handleSelectHostSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
activeHostServerId={activeHostServerId}
|
||||
layout="desktop"
|
||||
/>
|
||||
<View style={desktopStyles.contentPane}>
|
||||
<ScreenHeader
|
||||
borderless={!detailHeader}
|
||||
windowControlsPaddingRole="detailHeader"
|
||||
left={
|
||||
detailHeader ? (
|
||||
<>
|
||||
<HeaderIconBadge>
|
||||
<detailHeader.Icon
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</HeaderIconBadge>
|
||||
<ScreenTitle testID="settings-detail-header-title">
|
||||
{detailHeader.title}
|
||||
</ScreenTitle>
|
||||
{detailHeader.titleAccessory}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
leftStyle={desktopStyles.detailLeft}
|
||||
<WindowChromeRegion corners="top-left">
|
||||
<SettingsSidebar
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHostSection={handleSelectHostSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
activeHostServerId={activeHostServerId}
|
||||
layout="desktop"
|
||||
/>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</WindowChromeRegion>
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
|
||||
<ScreenHeader
|
||||
borderless={!detailHeader}
|
||||
left={desktopDetailHeaderLeft}
|
||||
leftStyle={desktopStyles.detailLeft}
|
||||
/>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</WindowChromeRegion>
|
||||
</View>
|
||||
{addHostModals}
|
||||
</View>
|
||||
@@ -1654,7 +1650,7 @@ const desktopStyles = StyleSheet.create((theme) => ({
|
||||
|
||||
const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
desktopContainer: {
|
||||
width: 320,
|
||||
width: SETTINGS_DESKTOP_SIDEBAR_WIDTH,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { SplitContainer } from "@/components/split-container";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { WindowChromeRegion } from "@/utils/desktop-window";
|
||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
import { WorkspaceActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
@@ -1566,6 +1568,46 @@ function shouldShowWorkspaceExplorerSidebar(input: {
|
||||
return !input.isMobile && input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
|
||||
}
|
||||
|
||||
interface WorkspaceChromeRowProps extends Omit<
|
||||
ComponentProps<typeof ExplorerSidebar>,
|
||||
"workspaceRoot"
|
||||
> {
|
||||
children: ReactNode;
|
||||
explorerOpen: boolean;
|
||||
portalHostName: string;
|
||||
showExplorerSidebar: boolean;
|
||||
workspaceRoot: string | null;
|
||||
}
|
||||
|
||||
function WorkspaceChromeRow({
|
||||
children,
|
||||
explorerOpen,
|
||||
portalHostName,
|
||||
showExplorerSidebar,
|
||||
workspaceRoot,
|
||||
...explorerProps
|
||||
}: WorkspaceChromeRowProps) {
|
||||
const explorerRendered = showExplorerSidebar && explorerOpen && workspaceRoot !== null;
|
||||
|
||||
return (
|
||||
<View style={styles.threePaneRow}>
|
||||
<WindowChromeRegion corners={explorerRendered ? "top-left" : "both"}>
|
||||
<FloatingPanelPortalHostNameProvider hostName={portalHostName}>
|
||||
{children}
|
||||
</FloatingPanelPortalHostNameProvider>
|
||||
</WindowChromeRegion>
|
||||
|
||||
<FloatingPanelPortalHost name={portalHostName} />
|
||||
|
||||
{showExplorerSidebar && workspaceRoot ? (
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<ExplorerSidebar {...explorerProps} workspaceRoot={workspaceRoot} />
|
||||
</WindowChromeRegion>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
@@ -3624,23 +3666,18 @@ function WorkspaceScreenContent({
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isRouteFocused={isRouteFocused}
|
||||
/>
|
||||
<View style={styles.threePaneRow}>
|
||||
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
|
||||
{workspaceCenterColumn}
|
||||
</FloatingPanelPortalHostNameProvider>
|
||||
|
||||
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
|
||||
|
||||
{showExplorerSidebar && workspaceDirectory ? (
|
||||
<ExplorerSidebar
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<WorkspaceChromeRow
|
||||
portalHostName={workspaceFloatingPanelPortalHostName}
|
||||
showExplorerSidebar={showExplorerSidebar}
|
||||
explorerOpen={isExplorerOpen}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
>
|
||||
{workspaceCenterColumn}
|
||||
</WorkspaceChromeRow>
|
||||
<ImportSessionSheet
|
||||
visible={isImportSheetVisible}
|
||||
client={client}
|
||||
|
||||
@@ -20,7 +20,7 @@ StyleSheet.configure({
|
||||
breakpoints: {
|
||||
xs: 0,
|
||||
sm: 576,
|
||||
md: 768,
|
||||
md: 720,
|
||||
lg: 992,
|
||||
xl: 1200,
|
||||
},
|
||||
|
||||
@@ -1,107 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveRawWindowControlsPadding,
|
||||
resolveWindowControlsPadding,
|
||||
intersectWindowChromeCorners,
|
||||
resolveWindowChromeObstruction,
|
||||
resolveWindowChromeSafeArea,
|
||||
} from "@/utils/desktop-window";
|
||||
|
||||
const rawPadding = {
|
||||
left: 80,
|
||||
right: 48,
|
||||
top: 28,
|
||||
};
|
||||
|
||||
describe("resolveWindowControlsPadding", () => {
|
||||
it("keeps mac traffic-light padding available when the app window is not fullscreen", () => {
|
||||
describe("window chrome", () => {
|
||||
it("has no corner obstruction outside Electron or in fullscreen", () => {
|
||||
expect(
|
||||
resolveRawWindowControlsPadding({ isElectron: true, isMac: true, isFullscreen: false }),
|
||||
).toEqual({
|
||||
left: 78,
|
||||
right: 0,
|
||||
top: 45,
|
||||
});
|
||||
resolveWindowChromeObstruction({ isElectron: false, isMac: true, isFullscreen: false }),
|
||||
).toEqual({ topLeft: null, topRight: null });
|
||||
expect(
|
||||
resolveWindowChromeObstruction({ isElectron: true, isMac: true, isFullscreen: true }),
|
||||
).toEqual({ topLeft: null, topRight: null });
|
||||
});
|
||||
|
||||
it("keeps Windows and Linux window-control padding available when the app window is not fullscreen", () => {
|
||||
it("places native controls in their physical top corner", () => {
|
||||
expect(
|
||||
resolveRawWindowControlsPadding({ isElectron: true, isMac: false, isFullscreen: false }),
|
||||
).toEqual({
|
||||
left: 0,
|
||||
right: 140,
|
||||
top: 48,
|
||||
});
|
||||
resolveWindowChromeObstruction({ isElectron: true, isMac: true, isFullscreen: false }),
|
||||
).toEqual({ topLeft: { width: 78, height: 45 }, topRight: null });
|
||||
expect(
|
||||
resolveWindowChromeObstruction({ isElectron: true, isMac: false, isFullscreen: false }),
|
||||
).toEqual({ topLeft: null, topRight: { width: 140, height: 48 } });
|
||||
});
|
||||
|
||||
it("does not reserve window-control padding when the app window is fullscreen", () => {
|
||||
it("insets and reserves only claimed corners", () => {
|
||||
const obstruction = { topLeft: { width: 80, height: 28 }, topRight: { width: 48, height: 32 } };
|
||||
expect(
|
||||
resolveRawWindowControlsPadding({ isElectron: true, isMac: true, isFullscreen: true }),
|
||||
).toEqual({
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
});
|
||||
resolveWindowChromeSafeArea({ obstruction, corners: "top-left", placement: "inline" }),
|
||||
).toEqual({ paddingLeft: 80, paddingRight: 0 });
|
||||
expect(
|
||||
resolveWindowChromeSafeArea({ obstruction, corners: "top-right", placement: "below" }),
|
||||
).toEqual({ height: 32 });
|
||||
expect(
|
||||
resolveWindowChromeSafeArea({ obstruction, corners: "both", placement: "below" }),
|
||||
).toEqual({ height: 32 });
|
||||
expect(
|
||||
resolveWindowChromeSafeArea({ obstruction, corners: "top-right", placement: "inline" }),
|
||||
).toEqual({ paddingLeft: 0, paddingRight: 48 });
|
||||
});
|
||||
|
||||
it("pads the main header for window controls when the app sidebar is closed", () => {
|
||||
expect(
|
||||
resolveWindowControlsPadding({
|
||||
role: "header",
|
||||
rawPadding,
|
||||
sidebarClosed: true,
|
||||
explorerOpen: false,
|
||||
focusModeEnabled: false,
|
||||
}),
|
||||
).toEqual({
|
||||
left: 80,
|
||||
right: 48,
|
||||
top: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add left padding to detail headers with their own sidebar", () => {
|
||||
expect(
|
||||
resolveWindowControlsPadding({
|
||||
role: "detailHeader",
|
||||
rawPadding,
|
||||
sidebarClosed: true,
|
||||
explorerOpen: false,
|
||||
focusModeEnabled: false,
|
||||
}),
|
||||
).toEqual({
|
||||
left: 0,
|
||||
right: 48,
|
||||
top: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("pads a focus-mode tab row away from mac traffic lights even when the sidebar is logically open", () => {
|
||||
expect(
|
||||
resolveWindowControlsPadding({
|
||||
role: "tabRow",
|
||||
rawPadding,
|
||||
sidebarClosed: false,
|
||||
explorerOpen: false,
|
||||
focusModeEnabled: true,
|
||||
}),
|
||||
).toEqual({
|
||||
left: 80,
|
||||
right: 48,
|
||||
top: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("pads a focus-mode tab row away from right-side window controls even when the explorer is logically open", () => {
|
||||
expect(
|
||||
resolveWindowControlsPadding({
|
||||
role: "tabRow",
|
||||
rawPadding: { left: 0, right: 140, top: 48 },
|
||||
sidebarClosed: true,
|
||||
explorerOpen: true,
|
||||
focusModeEnabled: true,
|
||||
}),
|
||||
).toEqual({
|
||||
left: 0,
|
||||
right: 140,
|
||||
top: 0,
|
||||
});
|
||||
it("intersects identical and empty corner claims", () => {
|
||||
expect(intersectWindowChromeCorners("both", "both")).toBe("both");
|
||||
expect(intersectWindowChromeCorners("top-left", "top-left")).toBe("top-left");
|
||||
expect(intersectWindowChromeCorners("none", "both")).toBe("none");
|
||||
expect(intersectWindowChromeCorners("both", "none")).toBe("none");
|
||||
expect(intersectWindowChromeCorners("both", "top-left")).toBe("top-left");
|
||||
expect(intersectWindowChromeCorners("top-right", "both")).toBe("top-right");
|
||||
expect(intersectWindowChromeCorners("top-left", "top-right")).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getIsElectronRuntimeMac,
|
||||
getIsElectronRuntime,
|
||||
DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
DESKTOP_WINDOW_CONTROLS_WIDTH,
|
||||
DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||
} from "@/constants/layout";
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
interface RawWindowControlsPadding {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
type WindowControlsPaddingRole =
|
||||
| "sidebar"
|
||||
| "header"
|
||||
| "detailHeader"
|
||||
| "tabRow"
|
||||
| "explorerSidebar";
|
||||
|
||||
// Module-level cache so hook remounts (e.g., on navigation) don't briefly
|
||||
// fall back to the default `false` while the async fullscreen check resolves.
|
||||
// Without this, in fullscreen the sidebar flashes with traffic-light padding
|
||||
// on first frame and then snaps to 0 once the async read completes.
|
||||
let cachedIsFullscreen = false;
|
||||
const fullscreenSubscribers = new Set<(value: boolean) => void>();
|
||||
let fullscreenSubscriptionStarted = false;
|
||||
|
||||
function setCachedFullscreen(value: boolean) {
|
||||
if (cachedIsFullscreen === value) return;
|
||||
cachedIsFullscreen = value;
|
||||
for (const sub of fullscreenSubscribers) {
|
||||
sub(value);
|
||||
}
|
||||
}
|
||||
|
||||
function startFullscreenSubscription() {
|
||||
if (fullscreenSubscriptionStarted) return;
|
||||
if (isNative || !getIsElectronRuntime()) return;
|
||||
fullscreenSubscriptionStarted = true;
|
||||
|
||||
void (async () => {
|
||||
const win = getDesktopWindow();
|
||||
if (!win) return;
|
||||
|
||||
if (typeof win.isFullscreen === "function") {
|
||||
try {
|
||||
setCachedFullscreen(await win.isFullscreen());
|
||||
} catch (error) {
|
||||
console.warn("[DesktopWindow] Failed to read fullscreen state", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof win.onResized !== "function") return;
|
||||
|
||||
try {
|
||||
await win.onResized(async () => {
|
||||
if (typeof win.isFullscreen !== "function") return;
|
||||
try {
|
||||
setCachedFullscreen(await win.isFullscreen());
|
||||
} catch (error) {
|
||||
console.warn("[DesktopWindow] Failed to read fullscreen state", error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[DesktopWindow] Failed to subscribe to resize", error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function useRawWindowControlsPadding(): RawWindowControlsPadding {
|
||||
const [isFullscreen, setIsFullscreen] = useState(cachedIsFullscreen);
|
||||
|
||||
useEffect(() => {
|
||||
startFullscreenSubscription();
|
||||
// Sync to any value that resolved between render and effect.
|
||||
setIsFullscreen(cachedIsFullscreen);
|
||||
fullscreenSubscribers.add(setIsFullscreen);
|
||||
return () => {
|
||||
fullscreenSubscribers.delete(setIsFullscreen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return resolveRawWindowControlsPadding({
|
||||
isElectron: getIsElectronRuntime(),
|
||||
isMac: getIsElectronRuntimeMac(),
|
||||
isFullscreen,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveRawWindowControlsPadding(input: {
|
||||
isElectron: boolean;
|
||||
isMac: boolean;
|
||||
isFullscreen: boolean;
|
||||
}): RawWindowControlsPadding {
|
||||
if (!input.isElectron || input.isFullscreen) {
|
||||
return { left: 0, right: 0, top: 0 };
|
||||
}
|
||||
|
||||
if (input.isMac) {
|
||||
return {
|
||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
right: 0,
|
||||
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
left: 0,
|
||||
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
|
||||
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWindowControlsPadding(role: WindowControlsPaddingRole): {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
} {
|
||||
const sidebarOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const explorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const focusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const rawPadding = useRawWindowControlsPadding();
|
||||
const sidebarClosed = !sidebarOpen;
|
||||
|
||||
const { left, right, top } = resolveWindowControlsPadding({
|
||||
role,
|
||||
rawPadding,
|
||||
sidebarClosed,
|
||||
explorerOpen,
|
||||
focusModeEnabled,
|
||||
});
|
||||
|
||||
return useMemo(() => ({ left, right, top }), [left, right, top]);
|
||||
}
|
||||
|
||||
export function resolveWindowControlsPadding(input: {
|
||||
role: WindowControlsPaddingRole;
|
||||
rawPadding: RawWindowControlsPadding;
|
||||
sidebarClosed: boolean;
|
||||
explorerOpen: boolean;
|
||||
focusModeEnabled: boolean;
|
||||
}): RawWindowControlsPadding {
|
||||
if (input.role === "sidebar") {
|
||||
return {
|
||||
left: input.rawPadding.left,
|
||||
right: 0,
|
||||
top: input.rawPadding.top,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.role === "header") {
|
||||
return {
|
||||
left: input.sidebarClosed ? input.rawPadding.left : 0,
|
||||
right: input.explorerOpen ? 0 : input.rawPadding.right,
|
||||
top: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.role === "detailHeader") {
|
||||
return {
|
||||
left: 0,
|
||||
right: input.rawPadding.right,
|
||||
top: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.role === "tabRow") {
|
||||
return {
|
||||
left: input.focusModeEnabled ? input.rawPadding.left : 0,
|
||||
right: input.focusModeEnabled ? input.rawPadding.right : 0,
|
||||
top: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
left: 0,
|
||||
right: input.rawPadding.right,
|
||||
top: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import {
|
||||
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||
DESKTOP_WINDOW_CONTROLS_WIDTH,
|
||||
getIsElectronRuntime,
|
||||
getIsElectronRuntimeMac,
|
||||
} from "@/constants/layout";
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
export type WindowChromeCorners = "none" | "top-left" | "top-right" | "both";
|
||||
type WindowChromeSafeAreaPlacement = "inline" | "below";
|
||||
|
||||
interface WindowChromeCornerObstruction {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface WindowChromeObstruction {
|
||||
topLeft: WindowChromeCornerObstruction | null;
|
||||
topRight: WindowChromeCornerObstruction | null;
|
||||
}
|
||||
|
||||
type WindowChromeSafeAreaStyle = { height: number } | { paddingLeft: number; paddingRight: number };
|
||||
|
||||
const EMPTY_OBSTRUCTION: WindowChromeObstruction = { topLeft: null, topRight: null };
|
||||
const WindowChromeContext = createContext<WindowChromeObstruction>(EMPTY_OBSTRUCTION);
|
||||
const WindowChromeCornersContext = createContext<WindowChromeCorners>("none");
|
||||
|
||||
function windowChromeCornersFromFlags(topLeft: boolean, topRight: boolean): WindowChromeCorners {
|
||||
if (topLeft && topRight) return "both";
|
||||
if (topLeft) return "top-left";
|
||||
if (topRight) return "top-right";
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function intersectWindowChromeCorners(
|
||||
inherited: WindowChromeCorners,
|
||||
declared: WindowChromeCorners,
|
||||
): WindowChromeCorners {
|
||||
const inheritedTopLeft = inherited === "top-left" || inherited === "both";
|
||||
const inheritedTopRight = inherited === "top-right" || inherited === "both";
|
||||
const declaredTopLeft = declared === "top-left" || declared === "both";
|
||||
const declaredTopRight = declared === "top-right" || declared === "both";
|
||||
return windowChromeCornersFromFlags(
|
||||
inheritedTopLeft && declaredTopLeft,
|
||||
inheritedTopRight && declaredTopRight,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveWindowChromeObstruction(input: {
|
||||
isElectron: boolean;
|
||||
isMac: boolean;
|
||||
isFullscreen: boolean;
|
||||
}): WindowChromeObstruction {
|
||||
if (!input.isElectron || input.isFullscreen) return EMPTY_OBSTRUCTION;
|
||||
if (input.isMac) {
|
||||
return {
|
||||
topLeft: { width: DESKTOP_TRAFFIC_LIGHT_WIDTH, height: DESKTOP_TRAFFIC_LIGHT_HEIGHT },
|
||||
topRight: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
topLeft: null,
|
||||
topRight: { width: DESKTOP_WINDOW_CONTROLS_WIDTH, height: DESKTOP_WINDOW_CONTROLS_HEIGHT },
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWindowChromeSafeArea(input: {
|
||||
obstruction: WindowChromeObstruction;
|
||||
corners: WindowChromeCorners;
|
||||
placement: WindowChromeSafeAreaPlacement;
|
||||
}): WindowChromeSafeAreaStyle {
|
||||
const ownsTopLeft = input.corners === "top-left" || input.corners === "both";
|
||||
const ownsTopRight = input.corners === "top-right" || input.corners === "both";
|
||||
const topLeft = ownsTopLeft ? input.obstruction.topLeft : null;
|
||||
const topRight = ownsTopRight ? input.obstruction.topRight : null;
|
||||
if (input.placement === "below") {
|
||||
return { height: Math.max(topLeft?.height ?? 0, topRight?.height ?? 0) };
|
||||
}
|
||||
return { paddingLeft: topLeft?.width ?? 0, paddingRight: topRight?.width ?? 0 };
|
||||
}
|
||||
|
||||
export function WindowChromeProvider({ children }: { children: ReactNode }) {
|
||||
const [isElectronReady, setIsElectronReady] = useState(getIsElectronRuntime);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let dispose: (() => void) | undefined;
|
||||
let connecting = false;
|
||||
let retryCount = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function scheduleRetry(warnOnExhaustion = false) {
|
||||
if (!active || dispose || retryTimer) return;
|
||||
if (retryCount >= 40) {
|
||||
if (warnOnExhaustion) {
|
||||
console.warn("[DesktopWindow] Chrome bridge unavailable; window controls may overlap UI");
|
||||
}
|
||||
return;
|
||||
}
|
||||
retryCount += 1;
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = undefined;
|
||||
connect();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!active || dispose || connecting) return;
|
||||
if (!getIsElectronRuntime()) return scheduleRetry();
|
||||
const desktopWindow = getDesktopWindow();
|
||||
if (
|
||||
!desktopWindow ||
|
||||
typeof desktopWindow.isFullscreen !== "function" ||
|
||||
typeof desktopWindow.onResized !== "function"
|
||||
)
|
||||
return scheduleRetry(true);
|
||||
const readFullscreen = desktopWindow.isFullscreen;
|
||||
const subscribeToResized = desktopWindow.onResized;
|
||||
connecting = true;
|
||||
void (async () => {
|
||||
async function syncFullscreen() {
|
||||
try {
|
||||
const fullscreen = await readFullscreen();
|
||||
if (active) setIsFullscreen(fullscreen);
|
||||
} catch (error) {
|
||||
if (active) console.warn("[DesktopWindow] Failed to read fullscreen state", error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const nextDispose = await subscribeToResized(syncFullscreen);
|
||||
if (!active) return nextDispose();
|
||||
dispose = nextDispose;
|
||||
setIsElectronReady(true);
|
||||
await syncFullscreen();
|
||||
} catch (error) {
|
||||
if (active) console.warn("[DesktopWindow] Failed to subscribe to resize", error);
|
||||
} finally {
|
||||
connecting = false;
|
||||
if (!dispose) scheduleRetry();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (!isNative) connect();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
dispose?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const obstruction = useMemo(
|
||||
() =>
|
||||
resolveWindowChromeObstruction({
|
||||
isElectron: isElectronReady,
|
||||
isMac: getIsElectronRuntimeMac(),
|
||||
isFullscreen,
|
||||
}),
|
||||
[isElectronReady, isFullscreen],
|
||||
);
|
||||
return (
|
||||
<WindowChromeContext.Provider value={obstruction}>
|
||||
<WindowChromeCornersContext.Provider value="both">
|
||||
{children}
|
||||
</WindowChromeCornersContext.Provider>
|
||||
</WindowChromeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Narrows inherited corner ownership to the corners occupied by this child surface. */
|
||||
export function WindowChromeRegion({
|
||||
corners,
|
||||
children,
|
||||
}: {
|
||||
corners: WindowChromeCorners;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const inheritedCorners = useContext(WindowChromeCornersContext);
|
||||
const ownedCorners = intersectWindowChromeCorners(inheritedCorners, corners);
|
||||
return (
|
||||
<WindowChromeCornersContext.Provider value={ownedCorners}>
|
||||
{children}
|
||||
</WindowChromeCornersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Restarts ownership for a new physical viewport such as a Modal or full-window overlay. */
|
||||
export function WindowChromeRootRegion({
|
||||
corners,
|
||||
children,
|
||||
}: {
|
||||
corners: WindowChromeCorners;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<WindowChromeCornersContext.Provider value={corners}>
|
||||
{children}
|
||||
</WindowChromeCornersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWindowChromeCorners(): WindowChromeCorners {
|
||||
return useContext(WindowChromeCornersContext);
|
||||
}
|
||||
|
||||
type WindowChromeSafeAreaProps = ViewProps & {
|
||||
placement: WindowChromeSafeAreaPlacement;
|
||||
horizontalPadding?: number;
|
||||
};
|
||||
|
||||
export function WindowChromeSafeArea({
|
||||
placement,
|
||||
horizontalPadding = 0,
|
||||
style,
|
||||
...props
|
||||
}: WindowChromeSafeAreaProps) {
|
||||
const obstruction = useContext(WindowChromeContext);
|
||||
const corners = useContext(WindowChromeCornersContext);
|
||||
const safeAreaStyle = useMemo(() => {
|
||||
const resolved = resolveWindowChromeSafeArea({ obstruction, corners, placement });
|
||||
if (placement === "below") return resolved;
|
||||
const paddingLeft = "paddingLeft" in resolved ? resolved.paddingLeft : 0;
|
||||
const paddingRight = "paddingRight" in resolved ? resolved.paddingRight : 0;
|
||||
return {
|
||||
paddingLeft: paddingLeft + horizontalPadding,
|
||||
paddingRight: paddingRight + horizontalPadding,
|
||||
};
|
||||
}, [corners, horizontalPadding, obstruction, placement]);
|
||||
const combinedStyle = useMemo(() => [style, safeAreaStyle], [safeAreaStyle, style]);
|
||||
return <View {...props} style={combinedStyle} />;
|
||||
}
|
||||
Reference in New Issue
Block a user